@ttsc/metro 0.28.5 → 0.28.6

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.
@@ -14,20 +14,20 @@
14
14
  * `projectRoot` plus the resolved tsconfig's directory when it lies outside),
15
15
  * hashed with the exact walk universe the `@ttsc/unplugin` transform core
16
16
  * validates its own cache against.
17
- * - **Recorded out-of-walk inputs.** The transform core cannot walk files outside
18
- * the roots or under ignored directories, but the host-owned reference graph
19
- * (samchon/ttsc#718) reports them per transform. Workers record them into a
20
- * snapshot under `node_modules/.cache/ttsc-metro`; the next run's
21
- * `getCacheKey` re-hashes the recorded set.
17
+ * - **Recorded transform inputs.** The host-owned reference graph
18
+ * (samchon/ttsc#718) reports each transform's derived inputs. Workers retain
19
+ * them under `node_modules/.cache/ttsc-metro`, compare their generation state
20
+ * with the exact main-process key baseline, and batch one durable write per
21
+ * delivered module.
22
22
  *
23
23
  * Snapshot layout: one main file carrying a random epoch id plus per-worker
24
24
  * files with unique names, so concurrent workers never race a shared write.
25
- * `withTtsc` (the single config process, before workers exist) compacts worker
26
- * files into the main file. Readers take the union of every file, reading the
27
- * worker files strictly before the main file: the compactor renames the merged
28
- * main into place strictly before deleting a worker file, so a worker file that
29
- * disappears mid-read is always already merged into the main the reader loads
30
- * afterwards.
25
+ * `withTtsc` compacts worker files into the main file before its workers exist,
26
+ * under a process-shared lock for builds using the same project cache. Readers
27
+ * take the union of every file, reading the worker files strictly before the
28
+ * main file: the compactor renames the merged main into place strictly before
29
+ * deleting a worker file, so a worker file that disappears mid-read is always
30
+ * already merged into the main the reader loads afterwards.
31
31
  *
32
32
  * Sound degradations, by design:
33
33
  *
@@ -43,20 +43,36 @@
43
43
  * until a later run records the volatile declaration gone.
44
44
  * - A recorded input that disappears hashes as a stable `missing` marker, so
45
45
  * deletion and reappearance both move the key.
46
+ * - A worker state that differs from the static key's run baseline taints the
47
+ * observation; compaction rotates the epoch so even A -> B -> A cannot reuse
48
+ * output stored under the earlier A key.
46
49
  */
47
50
  import {
48
- collectExternalInputHashes,
49
- collectProjectInputHashes,
50
- isProjectWalkPath,
51
+ captureWatchInputBaseline,
52
+ captureWatchInputFileBaseline,
53
+ collectProjectInputHashSnapshot,
54
+ discoverNearestProjectTsconfig,
55
+ findNearestProjectTsconfig,
56
+ findProjectTsconfigs,
57
+ isWatchInputKeyBaseline,
51
58
  mergeMembershipPolicyOverlay,
52
59
  readProjectMembershipPolicy,
60
+ readTsconfigSourceSnapshot,
61
+ watchInputEvidenceMatchesBaseline,
62
+ } from "@ttsc/unplugin/api";
63
+ import type {
64
+ TtscProjectDiscoveryFilesystem,
65
+ TtscProjectTreeDiscoveryFilesystem,
66
+ TtscWatchInput,
67
+ TtscWatchInputBaseline,
68
+ TtscWatchInputKeyBaseline,
53
69
  } from "@ttsc/unplugin/api";
54
70
  import { createHash, randomBytes } from "node:crypto";
55
71
  import fs from "node:fs";
56
72
  import path from "node:path";
57
73
 
58
74
  /** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
59
- const SNAPSHOT_VERSION = 1;
75
+ const SNAPSHOT_VERSION = 2;
60
76
 
61
77
  /** Snapshot directory segments under the fingerprint base directory. */
62
78
  const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
@@ -73,24 +89,47 @@ const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
73
89
  /** Prefix used after a compactor atomically claims an immutable worker file. */
74
90
  const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
75
91
 
92
+ /** Directory lock serializing the one mutable main-snapshot rewrite. */
93
+ const SNAPSHOT_COMPACTION_LOCK = "snapshot-compaction.lock";
94
+
95
+ /** Complete owner record stored inside an atomically published lock. */
96
+ const SNAPSHOT_COMPACTION_OWNER = "owner.json";
97
+
98
+ /** Cache-key baseline file prefix, one immutable identity per Metro run. */
99
+ const KEY_BASELINE_PREFIX = "key-baseline-";
100
+
101
+ /** Run-token prefix that forces every transformer key to be non-reusable. */
102
+ const NON_REUSABLE_RUN_PREFIX = "nonce:";
103
+
76
104
  /** Union of the snapshot state readable on disk. */
77
105
  interface SnapshotState {
78
106
  /** Random epoch id minted when the main snapshot was created. */
79
107
  id: string;
80
- /** Absolute paths of every recorded out-of-walk input. */
108
+ /** Absolute paths of every recorded derived transform input. */
81
109
  files: string[];
82
110
  /** Whether any recorded transform declared volatile output. */
83
111
  volatile: boolean;
112
+ /** Whether a transform observed state different from its run's static key. */
113
+ tainted: boolean;
84
114
  }
85
115
 
86
116
  /** Serialized shape of the main and worker snapshot files. */
87
117
  interface SnapshotDocument {
88
118
  files: string[];
89
119
  id?: string;
120
+ tainted: boolean;
90
121
  version: number;
91
122
  volatile: boolean;
92
123
  }
93
124
 
125
+ /** Main-process input states that one Metro run's static key actually used. */
126
+ interface KeyBaselineDocument {
127
+ inputs: Record<string, TtscWatchInputKeyBaseline>;
128
+ runId: string;
129
+ staticInputs: string[];
130
+ version: number;
131
+ }
132
+
94
133
  /** Snapshot documents discovered during one directory scan. */
95
134
  interface SnapshotDocuments {
96
135
  corruptPaths: string[];
@@ -125,14 +164,27 @@ export function resolveFingerprintBase(
125
164
  * already inside the base walk (an explicit out-of-root `project`, or a
126
165
  * monorepo-root tsconfig discovered above the app). Matching the transform
127
166
  * core's own validation universe keeps the invariant simple: everything the
128
- * core treats as an input is fingerprinted, either by a walk here or by the
129
- * recorded out-of-walk snapshot.
167
+ * core treats as an input is fingerprinted by the walk, the recorded snapshot,
168
+ * or both.
130
169
  */
131
170
  export function fingerprintRoots(
132
171
  base: string,
133
172
  explicitProject: string | undefined,
134
173
  ): string[] {
135
- const tsconfig = resolveProjectTsconfig(base, explicitProject);
174
+ const explicit = normalizedExplicitProject(explicitProject);
175
+ return projectViewRoots(
176
+ base,
177
+ resolveProjectTsconfig(base, explicit),
178
+ explicit,
179
+ );
180
+ }
181
+
182
+ /** Roots covered by one selected project's static walk. */
183
+ function projectViewRoots(
184
+ base: string,
185
+ tsconfig: string,
186
+ explicitProject: string | undefined,
187
+ ): string[] {
136
188
  // Containment, not walk membership. The question here is whether the
137
189
  // tsconfig's directory already sits inside the subtree the base walk covers,
138
190
  // so that adding it would repeat the same walk. `isProjectWalkPath` answers a
@@ -140,14 +192,18 @@ export function fingerprintRoots(
140
192
  // stopped hashing files that cannot enter the program it began answering
141
193
  // `false` for every `tsconfig.json`, which returned the base twice and hashed
142
194
  // the whole project twice on every cache key (samchon/ttsc#1307).
195
+ const resolvedBase = path.resolve(base);
143
196
  const directory = path.dirname(path.resolve(tsconfig));
144
- const relative = path.relative(path.resolve(base), directory);
197
+ const relative = path.relative(resolvedBase, directory);
145
198
  const inside =
146
199
  relative === "" ||
147
200
  (relative !== ".." &&
148
201
  !relative.startsWith(`..${path.sep}`) &&
149
202
  !path.isAbsolute(relative));
150
- return inside ? [base] : [base, directory];
203
+ if (explicitProject === undefined && inside && directory !== resolvedBase) {
204
+ return [directory];
205
+ }
206
+ return inside ? [resolvedBase] : [resolvedBase, directory];
151
207
  }
152
208
 
153
209
  /**
@@ -165,10 +221,29 @@ export function fingerprintRoots(
165
221
  export interface TtscMetroProjectView {
166
222
  /** The base directory both fingerprint sides agree on. */
167
223
  readonly base: string;
224
+ /** Config candidates observed while selecting this transform's project. */
225
+ readonly discoveryInputs: readonly TtscWatchInput[];
168
226
  /** The caller's explicit `project`, if any. */
169
227
  readonly explicitProject: string | undefined;
170
228
  /** The membership policy resolved for that project. */
171
229
  readonly policy: ReturnType<typeof readProjectMembershipPolicy>;
230
+ /** The policy used by the routed static walk. */
231
+ readonly walkPolicy: ReturnType<typeof readProjectMembershipPolicy>;
232
+ /** Lexical roots whose fingerprint uses this project's policy. */
233
+ readonly roots: readonly string[];
234
+ /** The exact config selected for this project. */
235
+ readonly tsconfig: string;
236
+ }
237
+
238
+ /** One stable implicit-project view and the config graph that produced it. */
239
+ interface TtscMetroFingerprintProjectView extends TtscMetroProjectView {
240
+ readonly configSources: ReturnType<typeof readTsconfigSourceSnapshot>;
241
+ }
242
+
243
+ /** Stable routed projects plus every config candidate the map observed. */
244
+ interface TtscMetroFingerprintProjectMap {
245
+ readonly discoveryInputs: readonly string[];
246
+ readonly projects: readonly TtscMetroFingerprintProjectView[];
172
247
  }
173
248
 
174
249
  /**
@@ -181,134 +256,284 @@ export interface TtscMetroProjectView {
181
256
  export function resolveProjectView(props: {
182
257
  compilerOptions?: Record<string, unknown>;
183
258
  explicitProject?: string;
259
+ filename?: string;
260
+ projectDiscoveryFilesystem?: TtscProjectDiscoveryFilesystem;
184
261
  projectRoot?: string;
185
262
  }): TtscMetroProjectView {
186
263
  const base = resolveFingerprintBase(props.projectRoot);
187
- return {
264
+ const explicitProject = normalizedExplicitProject(props.explicitProject);
265
+ const start =
266
+ explicitProject === undefined && props.filename !== undefined
267
+ ? path.dirname(path.resolve(props.filename))
268
+ : base;
269
+ const discovery =
270
+ explicitProject === undefined
271
+ ? discoverNearestProjectTsconfig(start, props.projectDiscoveryFilesystem)
272
+ : undefined;
273
+ const tsconfig =
274
+ discovery === undefined
275
+ ? resolveProjectTsconfig(start, explicitProject)
276
+ : (discovery.file ?? path.resolve(process.cwd(), "tsconfig.json"));
277
+ const discoveryInputs =
278
+ discovery === undefined
279
+ ? [captureProjectDiscoveryInput(tsconfig)]
280
+ : discovery.candidates.map((candidate) =>
281
+ captureProjectDiscoveryInput(candidate.file, candidate.fileExists),
282
+ );
283
+ if (!discoveryInputs.some((input) => samePath(input.file, tsconfig))) {
284
+ discoveryInputs.push(captureProjectDiscoveryInput(tsconfig));
285
+ }
286
+ return createProjectView({
188
287
  base,
288
+ compilerOptions: props.compilerOptions,
289
+ discoveryInputs,
290
+ explicitProject,
291
+ tsconfig,
292
+ });
293
+ }
294
+
295
+ /** Create one cache view from an already selected project config. */
296
+ function createProjectView(props: {
297
+ base: string;
298
+ compilerOptions?: Record<string, unknown>;
299
+ discoveryInputs?: readonly TtscWatchInput[];
300
+ explicitProject: string | undefined;
301
+ tsconfig: string;
302
+ }): TtscMetroProjectView {
303
+ const policy = membershipPolicy(props.tsconfig, props.compilerOptions);
304
+ return {
305
+ base: props.base,
306
+ discoveryInputs: props.discoveryInputs ?? [],
189
307
  explicitProject: props.explicitProject,
190
- policy: membershipPolicy(
191
- resolveProjectTsconfig(base, props.explicitProject),
192
- props.compilerOptions,
193
- ),
308
+ policy,
309
+ roots: projectViewRoots(props.base, props.tsconfig, props.explicitProject),
310
+ tsconfig: props.tsconfig,
311
+ walkPolicy: policy,
194
312
  };
195
313
  }
196
314
 
197
- /**
198
- * The membership policy of one project, memoized per resolved tsconfig and
199
- * caller overlay.
200
- *
201
- * Every use of the walk pair has to ask the same policy, or the two halves
202
- * disagree about the same project. The walk hashes what the configuration can
203
- * admit, and `isProjectWalkPath` answers whether the walk covers a path, so a
204
- * permissive answer here would claim coverage the walk does not provide and the
205
- * input would be recorded nowhere at all (samchon/ttsc#1307).
206
- */
207
- const MEMBERSHIP_POLICIES = new Map<
208
- string,
209
- {
210
- policy: ReturnType<typeof readProjectMembershipPolicy>;
211
- sources: readonly string[];
212
- stamp: string;
315
+ /** Attach identity to the exact file predicate that selected a worker project. */
316
+ function captureProjectDiscoveryInput(
317
+ file: string,
318
+ selectedFileExists?: boolean,
319
+ ): TtscWatchInput {
320
+ const baseline = captureWatchInputFileBaseline(file);
321
+ if (baseline === undefined) {
322
+ return { file };
213
323
  }
214
- >();
324
+ const fileExists = selectedFileExists ?? baseline.fileExists;
325
+ return {
326
+ evidence: {
327
+ identity: baseline.identity,
328
+ missing: !fileExists,
329
+ state: {
330
+ codec: "predicates",
331
+ observation: { fileExists },
332
+ },
333
+ unavailable: fileExists ? undefined : "not-file",
334
+ },
335
+ file,
336
+ };
337
+ }
215
338
 
339
+ /** Resolve one project policy from source rather than trusting metadata alone. */
216
340
  function membershipPolicy(
217
341
  tsconfig: string,
218
342
  compilerOptions?: Record<string, unknown>,
219
343
  ): ReturnType<typeof readProjectMembershipPolicy> {
220
- // Keyed by the config's path and the caller's overlay, and never trusted on
221
- // the key alone: a hit is served only while the config's own stamp still
222
- // matches. A Metro worker outlives many runs, and this is consulted once per
223
- // delivered file, so a memo that trusted its key would hold the policy a
224
- // project had when the worker started. An edit adding `exclude` would then
225
- // leave the worker judging a file in-walk while the next run's walk skipped
226
- // it, which is precisely the both-sides-disagree hole this policy exists to
227
- // close.
228
- //
229
- // The overlay belongs in the key rather than the stamp because it is part of
230
- // the question, not part of any file: keyed by path alone the memo would hand
231
- // a caller who passed `allowJs` the policy resolved for a caller who did not.
232
- const key = [tsconfig, stableStringify(compilerOptions ?? {})].join(
233
- String.fromCharCode(0),
234
- );
235
- const existing = MEMBERSHIP_POLICIES.get(key);
236
- if (existing !== undefined && existing.stamp === stampOf(existing.sources)) {
237
- return existing.policy;
238
- }
239
- // The caller's compiler-options overlay wins for the compile, so it has to
240
- // win here too, exactly as it does in the adapter: a project given
241
- // `allowJs: true` through `withTtsc` has a wider program than its tsconfig
242
- // alone describes, and a narrower policy here would ask a different question
243
- // about the same project (samchon/ttsc#1316).
244
- const policy = mergeMembershipPolicyOverlay(
344
+ // The caller overlay wins here exactly as it does for the compile. Re-reading
345
+ // source is deliberate: a long-lived worker cannot validate config contents
346
+ // from mtime and size, because a same-stamp rewrite is legal on coarse or
347
+ // restored filesystems. `resolveProjectView` runs once per delivered module,
348
+ // and its result is shared by the batched recorder.
349
+ return mergeMembershipPolicyOverlay(
245
350
  readProjectMembershipPolicy(tsconfig),
246
351
  compilerOptions ?? {},
247
352
  path.dirname(path.resolve(tsconfig)),
248
353
  );
249
- // Stamp the whole `extends` chain, not the leaf. Adding `exclude` to a shared
250
- // `tsconfig.base.json` leaves the leaf's own mtime and size untouched while
251
- // changing every answer the policy gives, so a leaf-only stamp would keep the
252
- // worker on the pre-edit policy for its lifetime.
253
- const sources =
254
- policy.sources.length === 0 ? [tsconfig] : [...policy.sources];
255
- MEMBERSHIP_POLICIES.set(key, {
256
- policy,
257
- sources,
258
- stamp: stampOf(sources),
259
- });
260
- return policy;
261
- }
262
-
263
- /** A stamp over every config a policy was read from, in a stable order. */
264
- function stampOf(sources: readonly string[]): string {
265
- return sources
266
- .map((source) => {
267
- try {
268
- const stats = fs.statSync(source);
269
- // A directory occupying a candidate path can never be the config, so it
270
- // contributes its existence and not its modification time, which moves
271
- // whenever any child is added or removed (samchon/ttsc#1316).
272
- return stats.isDirectory()
273
- ? `${source}:directory`
274
- : `${source}:${stats.mtimeMs}:${stats.size}`;
275
- } catch {
276
- // Absent now. `readProjectMembershipPolicy` answers for that too, and
277
- // the policy must be re-asked once the config appears.
278
- return `${source}:absent`;
279
- }
280
- })
281
- .join("|");
282
354
  }
283
355
 
284
356
  /**
285
357
  * Locate the tsconfig governing the project, mirroring the transform core's
286
358
  * discovery: an explicit `project` resolves against the working directory;
287
359
  * otherwise ancestor directories starting at `base` are searched for a
288
- * `tsconfig.json`, falling back to `<base>/tsconfig.json`.
360
+ * `tsconfig.json` file, falling back to `<cwd>/tsconfig.json` like the shared
361
+ * transform core.
289
362
  */
290
363
  function resolveProjectTsconfig(
291
364
  base: string,
292
365
  explicitProject: string | undefined,
293
366
  ): string {
294
- if (explicitProject !== undefined && explicitProject.length !== 0) {
367
+ if (explicitProject !== undefined) {
295
368
  return path.isAbsolute(explicitProject)
296
369
  ? explicitProject
297
370
  : path.resolve(process.cwd(), explicitProject);
298
371
  }
299
- let current = base;
300
- while (true) {
301
- const candidate = path.join(current, "tsconfig.json");
302
- if (fs.existsSync(candidate)) {
303
- return candidate;
304
- }
305
- const parent = path.dirname(current);
306
- if (parent === current) {
307
- break;
372
+ const discovered = findNearestProjectTsconfig(base);
373
+ if (discovered !== undefined) {
374
+ return discovered;
375
+ }
376
+ return path.resolve(process.cwd(), "tsconfig.json");
377
+ }
378
+
379
+ /** Empty project strings carry the same implicit meaning as omission. */
380
+ function normalizedExplicitProject(
381
+ explicitProject: string | undefined,
382
+ ): string | undefined {
383
+ return explicitProject === undefined || explicitProject.length === 0
384
+ ? undefined
385
+ : explicitProject;
386
+ }
387
+
388
+ /** Resolve every implicit project whose files can be delivered below base. */
389
+ function fingerprintProjectViews(props: {
390
+ compilerOptions?: Record<string, unknown>;
391
+ explicitProject?: string;
392
+ projectDiscoveryFilesystem?: TtscProjectTreeDiscoveryFilesystem;
393
+ projectRoot?: string;
394
+ }): TtscMetroFingerprintProjectMap {
395
+ const primary = resolveProjectView(props);
396
+ if (primary.explicitProject !== undefined) {
397
+ return {
398
+ discoveryInputs: [primary.tsconfig],
399
+ projects: [stableFingerprintProjectView(primary, props.compilerOptions)],
400
+ };
401
+ }
402
+ const firstMap = findProjectTsconfigs(
403
+ primary.base,
404
+ props.projectDiscoveryFilesystem,
405
+ );
406
+ if (!firstMap.complete) {
407
+ throw new Error(
408
+ "Unable to enumerate Metro's implicit TypeScript projects.",
409
+ );
410
+ }
411
+ const projects = [
412
+ stableFingerprintProjectView(primary, props.compilerOptions),
413
+ ];
414
+ for (const tsconfig of firstMap.files) {
415
+ const resolved = path.resolve(tsconfig);
416
+ if (projects.some((project) => samePath(project.tsconfig, resolved))) {
417
+ continue;
308
418
  }
309
- current = parent;
419
+ projects.push(
420
+ stableFingerprintProjectView(
421
+ createProjectView({
422
+ base: primary.base,
423
+ compilerOptions: props.compilerOptions,
424
+ explicitProject: undefined,
425
+ tsconfig: resolved,
426
+ }),
427
+ props.compilerOptions,
428
+ ),
429
+ );
430
+ }
431
+ const secondMap = findProjectTsconfigs(
432
+ primary.base,
433
+ props.projectDiscoveryFilesystem,
434
+ );
435
+ const selectedAfter = resolveProjectView(props);
436
+ if (
437
+ !secondMap.complete ||
438
+ !sameProjectMap(firstMap.files, secondMap.files) ||
439
+ !sameProjectMap(firstMap.candidates, secondMap.candidates) ||
440
+ !samePath(primary.tsconfig, selectedAfter.tsconfig) ||
441
+ stableStringify(primary.discoveryInputs) !==
442
+ stableStringify(selectedAfter.discoveryInputs) ||
443
+ projects.some(
444
+ (project) =>
445
+ stableStringify(readTsconfigSourceSnapshot(project.tsconfig)) !==
446
+ stableStringify(project.configSources),
447
+ )
448
+ ) {
449
+ throw new Error("Metro's implicit TypeScript project map changed.");
310
450
  }
311
- return path.resolve(base, "tsconfig.json");
451
+ const routedRoots = projects.map((project) => project.roots[0]!);
452
+ return {
453
+ // `findProjectTsconfigs` covers candidates at and below Metro's base. The
454
+ // primary nearest-config search can also cross above that base to a
455
+ // monorepo config, and every rejected candidate on that ancestor path is
456
+ // just as capable of changing the selected project. Keep both sets in the
457
+ // main-process baseline so a worker does not taint every unchanged run for
458
+ // reporting a candidate the static key itself used.
459
+ discoveryInputs: [
460
+ ...new Set([
461
+ ...primary.discoveryInputs.map((input) => input.file),
462
+ ...firstMap.candidates,
463
+ ]),
464
+ ].sort(),
465
+ projects: projects.map((project, index) => {
466
+ const root = routedRoots[index]!;
467
+ const nestedRoots = routedRoots.filter(
468
+ (candidate, candidateIndex) =>
469
+ candidateIndex !== index &&
470
+ !samePath(candidate, root) &&
471
+ pathIsWithin(candidate, root),
472
+ );
473
+ return nestedRoots.length === 0
474
+ ? project
475
+ : {
476
+ ...project,
477
+ walkPolicy: {
478
+ ...project.policy,
479
+ excludedDirectories: [
480
+ ...project.policy.excludedDirectories,
481
+ ...nestedRoots,
482
+ ],
483
+ },
484
+ };
485
+ }),
486
+ };
487
+ }
488
+
489
+ /** Read one implicit project's policy between equal complete config snapshots. */
490
+ function stableFingerprintProjectView(
491
+ project: TtscMetroProjectView,
492
+ compilerOptions?: Record<string, unknown>,
493
+ ): TtscMetroFingerprintProjectView {
494
+ const before = readTsconfigSourceSnapshot(project.tsconfig);
495
+ const refreshed = createProjectView({
496
+ base: project.base,
497
+ compilerOptions,
498
+ explicitProject: project.explicitProject,
499
+ tsconfig: project.tsconfig,
500
+ });
501
+ const after = readTsconfigSourceSnapshot(project.tsconfig);
502
+ if (
503
+ before.some((entry) => entry.contents === null) ||
504
+ after.some((entry) => entry.contents === null) ||
505
+ stableStringify(before) !== stableStringify(after)
506
+ ) {
507
+ throw new Error("Unable to read a stable TypeScript project config graph.");
508
+ }
509
+ return { ...refreshed, configSources: after };
510
+ }
511
+
512
+ /** Whether two complete lexical config enumerations name the same paths. */
513
+ function sameProjectMap(
514
+ left: readonly string[],
515
+ right: readonly string[],
516
+ ): boolean {
517
+ return (
518
+ left.length === right.length &&
519
+ left.every((entry, index) => samePath(entry, right[index]!))
520
+ );
521
+ }
522
+
523
+ /** Host-platform equality for two resolved path spellings. */
524
+ function samePath(left: string, right: string): boolean {
525
+ return path.relative(path.resolve(left), path.resolve(right)) === "";
526
+ }
527
+
528
+ /** Whether one resolved path lies at or below another. */
529
+ function pathIsWithin(child: string, parent: string): boolean {
530
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
531
+ return (
532
+ relative === "" ||
533
+ (relative !== ".." &&
534
+ !relative.startsWith(`..${path.sep}`) &&
535
+ !path.isAbsolute(relative))
536
+ );
312
537
  }
313
538
 
314
539
  /**
@@ -319,52 +544,183 @@ function resolveProjectTsconfig(
319
544
  export function computeProjectFingerprint(props: {
320
545
  compilerOptions?: Record<string, unknown>;
321
546
  explicitProject?: string;
547
+ /** Test seam for proving that incomplete implicit enumeration fails closed. */
548
+ projectDiscoveryFilesystem?: TtscProjectTreeDiscoveryFilesystem;
322
549
  projectRoot?: string;
550
+ /** Private identity transported from `withTtsc` to this Metro run. */
551
+ runId?: string;
323
552
  }): string {
324
553
  try {
325
554
  const base = resolveFingerprintBase(props.projectRoot);
326
- const hash = createHash("sha256");
327
- // Judge the fingerprint's walk by the same configuration the compile
328
- // does. Metro folds this into one static key, so an entry the program
329
- // could never contain used to re-key every transformed file rather than
330
- // costing one compile the way it does for a bundler (samchon/ttsc#1307).
331
- //
332
- // The caller's compiler-options overlay is part of that configuration, and
333
- // has to reach the walk as well as the recorder. The two are the halves of
334
- // one cache key and run in different processes, so they agree only by
335
- // deriving from the same declared options: a walk resolved without the
336
- // overlay while the recorder resolves with it leaves an overlay-admitted
337
- // input in neither half, which is the both-sides-disagree hole in its
338
- // quietest form (samchon/ttsc#1316).
339
- const project = resolveProjectView({
340
- compilerOptions: props.compilerOptions,
341
- explicitProject: props.explicitProject,
342
- projectRoot: props.projectRoot,
343
- });
344
- for (const root of fingerprintRoots(base, props.explicitProject)) {
345
- hash.update(
346
- stableStringify(
347
- collectProjectInputHashes(root, undefined, undefined, project.policy),
348
- ),
349
- );
555
+ const before = observeProjectFingerprint(props);
556
+ const after = observeProjectFingerprint(props);
557
+ if (stableStringify(before) !== stableStringify(after)) {
558
+ throw new Error("Metro's project fingerprint changed while observed.");
350
559
  }
351
- const snapshot = readSnapshotState(base);
352
- if (snapshot === undefined || snapshot.volatile) {
353
- hash.update(nonce());
354
- } else {
355
- hash.update(`snapshot:${snapshot.id}`);
356
- hash.update(stableStringify(collectExternalInputHashes(snapshot.files)));
560
+ if (props.runId !== undefined) {
561
+ writeKeyBaseline(base, props.runId, after.inputs, after.staticInputs);
357
562
  }
563
+ const hash = createHash("sha256");
564
+ hash.update(stableStringify(after.fingerprint));
358
565
  return hash.digest("hex");
359
566
  } catch {
360
567
  return nonce();
361
568
  }
362
569
  }
363
570
 
571
+ /** One coherent static-key observation and the paths it proves. */
572
+ interface ProjectFingerprintObservation {
573
+ fingerprint: unknown;
574
+ inputs: Record<string, TtscWatchInputKeyBaseline>;
575
+ staticInputs: string[];
576
+ }
577
+
578
+ /** Build the complete value hashed by one static key. */
579
+ function observeProjectFingerprint(props: {
580
+ compilerOptions?: Record<string, unknown>;
581
+ explicitProject?: string;
582
+ projectDiscoveryFilesystem?: TtscProjectTreeDiscoveryFilesystem;
583
+ projectRoot?: string;
584
+ }): ProjectFingerprintObservation {
585
+ // Judge the fingerprint's walk by the same configuration the compile does.
586
+ // The caller overlay reaches this walk and the worker through the same
587
+ // serialized options, so neither side can silently describe another program.
588
+ const projectMap = fingerprintProjectViews(props);
589
+ const inputs: Record<string, TtscWatchInputKeyBaseline> = {};
590
+ const staticInputs = new Set<string>();
591
+ const configSources = new Map<
592
+ string,
593
+ { contents: string; identity: string }
594
+ >();
595
+ const projectFingerprints: unknown[] = [];
596
+ for (const candidate of projectMap.discoveryInputs) {
597
+ addDiscoveryBaselineInput(inputs, candidate, staticInputs);
598
+ }
599
+ for (const project of projectMap.projects) {
600
+ for (const source of project.configSources) {
601
+ const existing = configSources.get(source.path);
602
+ if (existing !== undefined && existing.contents !== source.contents) {
603
+ throw new Error("A TypeScript config changed during fingerprinting.");
604
+ }
605
+ const baseline = addBaselineInput(inputs, source.path, staticInputs);
606
+ configSources.set(source.path, {
607
+ contents: source.contents!,
608
+ identity: baseline.identity,
609
+ });
610
+ }
611
+ for (const root of project.roots) {
612
+ const snapshot = collectProjectInputHashSnapshot(
613
+ root,
614
+ undefined,
615
+ undefined,
616
+ project.walkPolicy,
617
+ );
618
+ if (!snapshot.complete) {
619
+ throw new Error("Unable to read a complete Metro project walk.");
620
+ }
621
+ const fingerprintedInputs: Record<
622
+ string,
623
+ { hash: string; identity: string }
624
+ > = {};
625
+ for (const [key, expected] of Object.entries(snapshot.hashes)) {
626
+ const file = path.resolve(root, key);
627
+ const baseline = addBaselineInput(inputs, file, staticInputs);
628
+ if (baseline.hostHash !== expected) {
629
+ throw new Error("A Metro project input changed while fingerprinted.");
630
+ }
631
+ fingerprintedInputs[key] = {
632
+ hash: expected,
633
+ identity: baseline.identity,
634
+ };
635
+ }
636
+ projectFingerprints.push({
637
+ inputs: fingerprintedInputs,
638
+ root,
639
+ tsconfig: project.tsconfig,
640
+ });
641
+ }
642
+ }
643
+ const snapshot = readSnapshotState(resolveFingerprintBase(props.projectRoot));
644
+ if (snapshot === undefined || snapshot.volatile || snapshot.tainted) {
645
+ throw new Error("Metro's recorded transform snapshot is not reusable.");
646
+ }
647
+ const recorded: Record<string, { hash: string; identity: string }> = {};
648
+ for (const file of snapshot.files) {
649
+ const baseline = addBaselineInput(inputs, file);
650
+ recorded[snapshotPathKey(file)] = {
651
+ hash: baseline.hostHash,
652
+ identity: baseline.identity,
653
+ };
654
+ }
655
+ return {
656
+ fingerprint: {
657
+ configSources: [...configSources].sort(([a], [b]) =>
658
+ a < b ? -1 : a > b ? 1 : 0,
659
+ ),
660
+ projects: projectFingerprints,
661
+ snapshot: { id: snapshot.id, inputs: recorded },
662
+ },
663
+ inputs,
664
+ staticInputs: [...staticInputs].sort(),
665
+ };
666
+ }
667
+
668
+ /** Add one lexical path's stable broad state to a key baseline. */
669
+ function addBaselineInput(
670
+ inputs: Record<string, TtscWatchInputKeyBaseline>,
671
+ file: string,
672
+ staticInputs?: Set<string>,
673
+ ): TtscWatchInputBaseline {
674
+ const key = snapshotPathKey(file);
675
+ const observed = captureWatchInputBaseline(file);
676
+ if (observed === undefined) {
677
+ throw new Error("Unable to read a stable Metro input baseline.");
678
+ }
679
+ const existing = inputs[key];
680
+ if (existing !== undefined) {
681
+ if (
682
+ existing.identity !== observed.identity ||
683
+ existing.fileExists !== observed.fileExists ||
684
+ ("hostHash" in existing &&
685
+ stableStringify(existing) !== stableStringify(observed))
686
+ ) {
687
+ throw new Error("A Metro input changed between baseline observations.");
688
+ }
689
+ inputs[key] = { ...existing, ...observed };
690
+ } else {
691
+ inputs[key] = observed;
692
+ }
693
+ staticInputs?.add(key);
694
+ return observed;
695
+ }
696
+
697
+ /** Add the stable file predicate used by the project-map traversal. */
698
+ function addDiscoveryBaselineInput(
699
+ inputs: Record<string, TtscWatchInputKeyBaseline>,
700
+ file: string,
701
+ staticInputs: Set<string>,
702
+ ): void {
703
+ const key = snapshotPathKey(file);
704
+ const observed = captureWatchInputFileBaseline(file);
705
+ if (observed === undefined) {
706
+ throw new Error("Unable to read a stable Metro project candidate.");
707
+ }
708
+ const existing = inputs[key];
709
+ if (
710
+ existing !== undefined &&
711
+ (existing.identity !== observed.identity ||
712
+ existing.fileExists !== observed.fileExists)
713
+ ) {
714
+ throw new Error("A Metro project candidate changed while observed.");
715
+ }
716
+ inputs[key] = existing ?? observed;
717
+ staticInputs.add(key);
718
+ }
719
+
364
720
  /**
365
721
  * A value no other run can reproduce. Folding it means this run's cache entries
366
722
  * are written but never reused by later runs, and this run reuses nothing from
367
- * earlier ones — the sound fallback whenever the recorded out-of-walk input set
723
+ * earlier ones — the sound fallback whenever the recorded transform input set
368
724
  * is unknown or unrepresentable.
369
725
  */
370
726
  function nonce(): string {
@@ -372,35 +728,47 @@ function nonce(): string {
372
728
  }
373
729
 
374
730
  /**
375
- * Prepare the snapshot for a new run. Called from `withTtsc` in the single
376
- * Metro config process, before any worker exists: creates the main snapshot
377
- * (fresh epoch id) when missing or corrupt, compacts leftover worker files into
378
- * it, and sweeps unparseable worker files plus crash-leftover temp files. An
379
- * unparseable worker file's recordings are unrecoverable, so its removal mints
380
- * a fresh epoch id every key that might have depended on the lost recordings
381
- * is soundly orphaned, and later runs stabilize instead of degrading to a nonce
382
- * forever. A failed rewrite leaves a recovery document outside the snapshot
383
- * directory so `getCacheKey` degrades to a nonce until a later compaction
384
- * succeeds. If an older readable main exists and neither location is writable,
385
- * preparation throws instead of authorizing stale reuse.
731
+ * Prepare the snapshot for a new run. Called from `withTtsc` before any worker
732
+ * exists: creates the main snapshot (fresh epoch id) when missing or corrupt,
733
+ * compacts leftover worker files into it, and sweeps unparseable worker files
734
+ * plus crash-leftover temp files. Concurrent config processes are serialized; a
735
+ * contender takes a non-reusable run token instead of racing the mutable main
736
+ * rewrite. An unparseable worker file's recordings are unrecoverable, so its
737
+ * removal mints a fresh epoch id every key that might have depended on the
738
+ * lost recordings is soundly orphaned, and later runs stabilize instead of
739
+ * degrading to a nonce forever. A failed rewrite leaves a recovery document
740
+ * outside the snapshot directory and returns a non-reusable run token. The
741
+ * token crosses bundle and process boundaries, so `getCacheKey` degrades to a
742
+ * nonce even when neither snapshot location can persist the failure and an old
743
+ * main later reappears.
386
744
  */
387
- export function prepareSnapshot(projectRoot: string | undefined): void {
745
+ export function prepareSnapshot(projectRoot: string | undefined): string {
388
746
  const base = resolveFingerprintBase(projectRoot);
389
- let hadReadableMain = false;
747
+ const runId = randomBytes(16).toString("hex");
748
+ let reusable = true;
390
749
  let pending: SnapshotDocument = {
391
750
  files: [],
751
+ tainted: false,
392
752
  version: SNAPSHOT_VERSION,
393
753
  volatile: false,
394
754
  };
755
+ let releaseCompactionLock: (() => void) | undefined;
395
756
  try {
396
757
  // A nonexistent base can never be a working Metro setup (Metro verifies
397
758
  // the project root exists), so preparing a snapshot there would only
398
759
  // materialize directory trees at arbitrary paths.
399
760
  if (!fs.existsSync(base)) {
400
- return;
761
+ return runId;
401
762
  }
402
763
  const directory = snapshotDirectory(base);
403
764
  fs.mkdirSync(directory, { recursive: true });
765
+ releaseCompactionLock = acquireSnapshotCompactionLock(directory);
766
+ if (releaseCompactionLock === undefined) {
767
+ // Another Metro config process is already rewriting the mutable main
768
+ // snapshot. It owns all pending worker documents, while this run takes a
769
+ // private nonce and therefore cannot reuse or publish under a stale key.
770
+ return `${NON_REUSABLE_RUN_PREFIX}${runId}`;
771
+ }
404
772
  // Read the worker files strictly before the main file (see the module doc
405
773
  // comment): a concurrent compactor deletes a worker file only after the
406
774
  // merged main is renamed into place, so whatever this enumeration misses
@@ -412,9 +780,9 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
412
780
  throw new Error("Unable to enumerate Metro snapshot state.");
413
781
  }
414
782
  const main = readMainDocument(directory);
415
- hadReadableMain = main !== undefined && typeof main.id === "string";
416
783
  const files = new Set(main?.files ?? []);
417
784
  const observations = [...recovery.entries, ...workers.entries];
785
+ const tainted = observations.some((entry) => entry.tainted);
418
786
  const volatile =
419
787
  observations.length === 0
420
788
  ? (main?.volatile ?? false)
@@ -434,9 +802,10 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
434
802
  pending = {
435
803
  files: [...files].sort(),
436
804
  id:
437
- !recovering && workers.corruptPaths.length === 0
805
+ !recovering && workers.corruptPaths.length === 0 && !tainted
438
806
  ? (main?.id ?? randomBytes(16).toString("hex"))
439
807
  : randomBytes(16).toString("hex"),
808
+ tainted: false,
440
809
  version: SNAPSHOT_VERSION,
441
810
  volatile,
442
811
  };
@@ -447,6 +816,7 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
447
816
  ...recovery.paths,
448
817
  ...recovery.corruptPaths,
449
818
  ...listTemporaryFiles(directory),
819
+ ...listExpiredKeyBaselines(directory),
450
820
  ]) {
451
821
  try {
452
822
  fs.rmSync(file, { force: true });
@@ -463,18 +833,145 @@ export function prepareSnapshot(projectRoot: string | undefined): void {
463
833
  ) {
464
834
  unhealthySnapshots.delete(base);
465
835
  }
466
- } catch (snapshotError) {
836
+ } catch {
837
+ reusable = false;
467
838
  try {
468
839
  persistUnhealthySnapshot(base, pending);
469
- } catch (recoveryError) {
470
- if (hadReadableMain || hasReadableMainSnapshot(base)) {
471
- throw new AggregateError(
472
- [snapshotError, recoveryError],
473
- "Unable to persist Metro snapshot state or its recovery record.",
474
- );
840
+ } catch {
841
+ // The returned token carries the failure when neither on-disk location
842
+ // can. No consumer may turn that token into a reusable cache key.
843
+ }
844
+ } finally {
845
+ if (releaseCompactionLock !== undefined) {
846
+ try {
847
+ releaseCompactionLock();
848
+ } catch {
849
+ reusable = false;
850
+ try {
851
+ persistUnhealthySnapshot(base, pending);
852
+ } catch {
853
+ // The returned non-reusable token remains the final safety boundary.
854
+ }
475
855
  }
476
856
  }
477
857
  }
858
+ return reusable ? runId : `${NON_REUSABLE_RUN_PREFIX}${runId}`;
859
+ }
860
+
861
+ /**
862
+ * Acquire the process-shared lock for the mutable main snapshot.
863
+ *
864
+ * A complete owner directory is published atomically, and no process waits
865
+ * while holding a reusable run identity. A contending process therefore
866
+ * degrades immediately to a nonce. The owner retires the directory atomically
867
+ * after every success or failure path has persisted its verdict.
868
+ */
869
+ function acquireSnapshotCompactionLock(
870
+ directory: string,
871
+ ): (() => void) | undefined {
872
+ const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
873
+ const token = randomBytes(16).toString("hex");
874
+ const candidate = path.join(
875
+ directory,
876
+ `.snapshot-compaction-${process.pid.toString(36)}-${token}`,
877
+ );
878
+ let candidateCreated = false;
879
+ try {
880
+ fs.mkdirSync(candidate);
881
+ candidateCreated = true;
882
+ fs.writeFileSync(
883
+ path.join(candidate, SNAPSHOT_COMPACTION_OWNER),
884
+ JSON.stringify({ pid: process.pid, token }),
885
+ "utf8",
886
+ );
887
+ if (fs.existsSync(lock)) {
888
+ reapDeadSnapshotCompactionLock(lock);
889
+ return undefined;
890
+ }
891
+ fs.renameSync(candidate, lock);
892
+ candidateCreated = false;
893
+ } catch (error) {
894
+ if (fs.existsSync(lock)) {
895
+ reapDeadSnapshotCompactionLock(lock);
896
+ return undefined;
897
+ }
898
+ throw error;
899
+ } finally {
900
+ if (candidateCreated) {
901
+ fs.rmSync(candidate, { force: true, recursive: true });
902
+ }
903
+ }
904
+ return () => {
905
+ const owner = readSnapshotCompactionOwner(lock);
906
+ if (owner?.pid !== process.pid || owner.token !== token) {
907
+ throw new Error("Metro snapshot compaction lock ownership changed.");
908
+ }
909
+ const retired = path.join(
910
+ directory,
911
+ `.snapshot-compaction-released-${token}`,
912
+ );
913
+ fs.renameSync(lock, retired);
914
+ try {
915
+ fs.rmSync(retired, { force: true, recursive: true });
916
+ } catch {
917
+ // A retired owner cannot block or be confused with the fixed lock name.
918
+ }
919
+ };
920
+ }
921
+
922
+ /** Read a complete lock owner; malformed state remains a conservative lock. */
923
+ function readSnapshotCompactionOwner(
924
+ lock: string,
925
+ ): { pid: number; token: string } | undefined {
926
+ try {
927
+ const value: unknown = JSON.parse(
928
+ fs.readFileSync(path.join(lock, SNAPSHOT_COMPACTION_OWNER), "utf8"),
929
+ );
930
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
931
+ return undefined;
932
+ }
933
+ const owner = value as Record<string, unknown>;
934
+ return Number.isSafeInteger(owner.pid) &&
935
+ (owner.pid as number) > 0 &&
936
+ typeof owner.token === "string" &&
937
+ /^[a-f0-9]{32}$/.test(owner.token)
938
+ ? { pid: owner.pid as number, token: owner.token }
939
+ : undefined;
940
+ } catch {
941
+ return undefined;
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Remove a lock whose recorded process is proven dead.
947
+ *
948
+ * The dead owner's random token also names its quarantine. At most one
949
+ * contender can move the fixed lock there. The quarantine remains as a tiny
950
+ * election record, so a delayed contender can never move a newer owner's lock
951
+ * after the first contender has recovered the fixed name.
952
+ */
953
+ function reapDeadSnapshotCompactionLock(lock: string): void {
954
+ const owner = readSnapshotCompactionOwner(lock);
955
+ if (owner === undefined || processIsAlive(owner.pid)) return;
956
+ const quarantine = path.join(
957
+ path.dirname(lock),
958
+ `.snapshot-compaction-stale-${owner.token}`,
959
+ );
960
+ try {
961
+ fs.renameSync(lock, quarantine);
962
+ } catch {
963
+ // Another contender recovered this owner, or the lock changed after read.
964
+ }
965
+ }
966
+
967
+ /** Treat every process-query failure except a definite missing PID as live. */
968
+ function processIsAlive(pid: number): boolean {
969
+ try {
970
+ process.kill(pid, 0);
971
+ return true;
972
+ } catch (error) {
973
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
974
+ }
478
975
  }
479
976
 
480
977
  /**
@@ -502,6 +999,29 @@ function listTemporaryFiles(directory: string): string[] {
502
999
  }
503
1000
  }
504
1001
 
1002
+ /** Old run baselines that cannot belong to an ordinary live Metro session. */
1003
+ function listExpiredKeyBaselines(directory: string): string[] {
1004
+ const horizon = Date.now() - 7 * 24 * 60 * 60 * 1000;
1005
+ try {
1006
+ return fs
1007
+ .readdirSync(directory)
1008
+ .filter(
1009
+ (name) =>
1010
+ name.startsWith(KEY_BASELINE_PREFIX) && name.endsWith(".json"),
1011
+ )
1012
+ .map((name) => path.join(directory, name))
1013
+ .filter((file) => {
1014
+ try {
1015
+ return fs.statSync(file).mtimeMs < horizon;
1016
+ } catch {
1017
+ return false;
1018
+ }
1019
+ });
1020
+ } catch {
1021
+ return [];
1022
+ }
1023
+ }
1024
+
505
1025
  /**
506
1026
  * Read the unioned snapshot state, or `undefined` when the main snapshot is
507
1027
  * missing or any snapshot file is corrupt (a torn or foreign write means the
@@ -531,26 +1051,27 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
531
1051
  }
532
1052
  const files = new Set(main.files);
533
1053
  let volatile = main.volatile;
1054
+ let tainted = main.tainted;
534
1055
  for (const entry of workers.entries) {
535
1056
  for (const file of entry.files) {
536
1057
  files.add(file);
537
1058
  }
538
1059
  volatile ||= entry.volatile;
1060
+ tainted ||= entry.tainted;
539
1061
  }
540
- return { files: [...files].sort(), id: main.id, volatile };
1062
+ return { files: [...files].sort(), id: main.id, tainted, volatile };
541
1063
  }
542
1064
 
543
1065
  /**
544
- * Recorder held by each Metro worker. It persists out-of-walk watch inputs and
545
- * missing in-walk paths delivered through the transform core's `addWatchFile`
546
- * hook, plus any volatile declaration. Existing in-walk files stay covered by
547
- * the project walk; a missing path must be retained because its creation is a
548
- * state change that the initial walk could not hash. A clean in-walk transform
549
- * also writes a document so it can clear a volatile declaration from an earlier
550
- * run. The unique name makes worker writes race-free; `withTtsc` compacts the
551
- * files on the next run.
1066
+ * Recorder held by each Metro worker. It persists every derived watch input and
1067
+ * any volatile declaration, compares compiler-generation evidence with the
1068
+ * matching main-process run baseline, and marks any temporal mismatch tainted.
1069
+ * A clean transform also writes a document so it can clear a volatile
1070
+ * declaration from an earlier run. One cumulative document is flushed per
1071
+ * delivered module; the unique name makes worker writes race-free, and
1072
+ * `withTtsc` compacts the files on the next run.
552
1073
  */
553
- export function createSnapshotRecorder(): {
1074
+ export function createSnapshotRecorder(runId?: string): {
554
1075
  record: (props: {
555
1076
  input: string;
556
1077
  /**
@@ -565,15 +1086,15 @@ export function createSnapshotRecorder(): {
565
1086
  * compiler-options overlay reached one half and not the other, and the
566
1087
  * input was then covered by neither (samchon/ttsc#1316).
567
1088
  *
568
- * Resolving it once per transform is also what makes it affordable.
569
- * `record` runs once per watch input rather than once per file, and
570
- * validating the memo means stat-ing the whole `extends` chain, measured at
571
- * 12 microseconds per stat — a few thousand modules times fifteen inputs
572
- * each cost over half a second per run for an answer that cannot change
573
- * between two inputs of one file.
1089
+ * Resolving it once per transform also keeps every entry in the module's
1090
+ * batch attached to the exact same config graph and policy.
574
1091
  */
575
1092
  project: TtscMetroProjectView;
576
1093
  }) => void;
1094
+ recordMany: (props: {
1095
+ inputs: readonly TtscWatchInput[];
1096
+ project: TtscMetroProjectView;
1097
+ }) => void;
577
1098
  recordVolatile: (props: { project: TtscMetroProjectView }) => void;
578
1099
  } {
579
1100
  const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
@@ -581,10 +1102,49 @@ export function createSnapshotRecorder(): {
581
1102
  dirty: boolean;
582
1103
  files: Set<string>;
583
1104
  observed: boolean;
584
- roots: string[];
1105
+ tainted: boolean;
585
1106
  volatile: boolean;
586
1107
  }
587
1108
  const states = new Map<string, BaseState>();
1109
+ const baselines = new Map<string, KeyBaselineDocument | null>();
1110
+ const baselineStaticInputs = new Map<string, Set<string>>();
1111
+
1112
+ function keyBaselineCoverage(
1113
+ input: TtscWatchInput,
1114
+ base: string,
1115
+ ): { matches: boolean; static: boolean } {
1116
+ // A direct recorder without the private run handshake cannot prove that
1117
+ // any input belongs to the main process's static key. Retain every path
1118
+ // without claiming a temporal mismatch. Production always receives a run
1119
+ // id from `withTtsc`; an absent or unreadable matching baseline then fails
1120
+ // closed.
1121
+ if (runId === undefined) {
1122
+ return { matches: true, static: false };
1123
+ }
1124
+ let baseline = baselines.get(base);
1125
+ if (baseline === undefined) {
1126
+ baseline = readKeyBaseline(base, runId) ?? null;
1127
+ baselines.set(base, baseline);
1128
+ baselineStaticInputs.set(base, new Set(baseline?.staticInputs ?? []));
1129
+ }
1130
+ const key = snapshotPathKey(input.file);
1131
+ const expected = baseline?.inputs[key];
1132
+ try {
1133
+ const matches =
1134
+ expected !== undefined &&
1135
+ input.evidence !== undefined &&
1136
+ watchInputEvidenceMatchesBaseline(input.evidence, expected);
1137
+ return {
1138
+ matches,
1139
+ static:
1140
+ matches &&
1141
+ baseline !== null &&
1142
+ baselineStaticInputs.get(base)?.has(key) === true,
1143
+ };
1144
+ } catch {
1145
+ return { matches: false, static: false };
1146
+ }
1147
+ }
588
1148
 
589
1149
  function stateFor(project: TtscMetroProjectView): BaseState {
590
1150
  const base = project.base;
@@ -594,7 +1154,7 @@ export function createSnapshotRecorder(): {
594
1154
  dirty: false,
595
1155
  files: new Set(),
596
1156
  observed: false,
597
- roots: fingerprintRoots(base, project.explicitProject),
1157
+ tainted: false,
598
1158
  volatile: false,
599
1159
  };
600
1160
  states.set(base, state);
@@ -608,6 +1168,7 @@ export function createSnapshotRecorder(): {
608
1168
  }
609
1169
  const document: SnapshotDocument = {
610
1170
  files: [...state.files].sort(),
1171
+ tainted: state.tainted,
611
1172
  version: SNAPSHOT_VERSION,
612
1173
  volatile: state.volatile,
613
1174
  };
@@ -625,7 +1186,13 @@ export function createSnapshotRecorder(): {
625
1186
  try {
626
1187
  persistUnhealthySnapshot(base, document);
627
1188
  } catch (recoveryError) {
628
- if (hasReadableMainSnapshot(base)) {
1189
+ // A reusable run id proves that the main process authorized a cache
1190
+ // key. The explicit nonce token is different: it guarantees that this
1191
+ // run's output cannot be reused, so losing its observation is safe.
1192
+ if (
1193
+ isReusableSnapshotRunId(runId) ||
1194
+ (runId === undefined && hasReadableMainSnapshot(base))
1195
+ ) {
629
1196
  throw new AggregateError(
630
1197
  [snapshotError, recoveryError],
631
1198
  "Unable to persist a Metro snapshot observation or its recovery record.",
@@ -635,39 +1202,41 @@ export function createSnapshotRecorder(): {
635
1202
  }
636
1203
  }
637
1204
 
638
- return {
639
- record(props) {
640
- const base = props.project.base;
641
- const state = stateFor(props.project);
642
- const input = path.resolve(props.input);
643
- const firstObservation = !state.observed;
644
- state.observed = true;
645
- if (
646
- state.files.has(input) ||
647
- (fs.existsSync(input) &&
648
- state.roots.some((root) =>
649
- isProjectWalkPath(
650
- root,
651
- input,
652
- undefined,
653
- undefined,
654
- props.project.policy,
655
- ),
656
- ))
657
- ) {
658
- // Even when every input belongs to the project walk, the worker must
659
- // publish that it performed a clean transform. Otherwise an old main
660
- // snapshot with `volatile: true` remains sticky forever.
661
- if (firstObservation || state.dirty) {
662
- state.dirty = true;
663
- flush(base, state);
664
- }
665
- return;
1205
+ function recordMany(props: {
1206
+ inputs: readonly TtscWatchInput[];
1207
+ project: TtscMetroProjectView;
1208
+ }): void {
1209
+ const base = props.project.base;
1210
+ const state = stateFor(props.project);
1211
+ const firstObservation = !state.observed;
1212
+ state.observed = true;
1213
+ for (const input of props.inputs) {
1214
+ const file = path.resolve(input.file);
1215
+ const coverage = keyBaselineCoverage({ ...input, file }, base);
1216
+ if (!coverage.matches) {
1217
+ state.tainted = true;
1218
+ }
1219
+ if (!coverage.static && !state.files.has(file)) {
1220
+ state.files.add(file);
1221
+ state.dirty = true;
666
1222
  }
667
- state.files.add(input);
1223
+ }
1224
+ // A clean empty delivery must still clear a volatile verdict from the
1225
+ // preceding run. Persist once for the whole module, not once per input.
1226
+ if (firstObservation || state.tainted) {
668
1227
  state.dirty = true;
669
- flush(base, state);
1228
+ }
1229
+ flush(base, state);
1230
+ }
1231
+
1232
+ return {
1233
+ record(props) {
1234
+ recordMany({
1235
+ inputs: [{ file: props.input }],
1236
+ project: props.project,
1237
+ });
670
1238
  },
1239
+ recordMany,
671
1240
  recordVolatile(props) {
672
1241
  const base = props.project.base;
673
1242
  const state = stateFor(props.project);
@@ -682,6 +1251,96 @@ export function createSnapshotRecorder(): {
682
1251
  };
683
1252
  }
684
1253
 
1254
+ /** Filesystem-keyed lexical spelling used by main and worker processes. */
1255
+ function snapshotPathKey(file: string): string {
1256
+ const resolved = path.resolve(file);
1257
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
1258
+ }
1259
+
1260
+ /** Persist the exact filesystem state one run's static key observed. */
1261
+ function writeKeyBaseline(
1262
+ base: string,
1263
+ runId: string,
1264
+ inputs: Record<string, TtscWatchInputKeyBaseline>,
1265
+ staticInputs: string[],
1266
+ ): void {
1267
+ if (!isReusableSnapshotRunId(runId)) {
1268
+ throw new Error("Invalid Metro snapshot run identity.");
1269
+ }
1270
+ const file = path.join(
1271
+ snapshotDirectory(base),
1272
+ `${KEY_BASELINE_PREFIX}${runId}.json`,
1273
+ );
1274
+ if (fs.existsSync(file)) {
1275
+ const existing = readKeyBaseline(base, runId);
1276
+ if (
1277
+ existing === undefined ||
1278
+ stableStringify(existing.inputs) !== stableStringify(inputs) ||
1279
+ stableStringify(existing.staticInputs) !== stableStringify(staticInputs)
1280
+ ) {
1281
+ throw new Error("A Metro run attempted to replace its key baseline.");
1282
+ }
1283
+ return;
1284
+ }
1285
+ writeSnapshotDocument(file, {
1286
+ inputs,
1287
+ runId,
1288
+ staticInputs,
1289
+ version: SNAPSHOT_VERSION,
1290
+ });
1291
+ }
1292
+
1293
+ /** Read only the immutable baseline belonging to this worker's run. */
1294
+ function readKeyBaseline(
1295
+ base: string,
1296
+ runId: string,
1297
+ ): KeyBaselineDocument | undefined {
1298
+ if (!isReusableSnapshotRunId(runId)) {
1299
+ return undefined;
1300
+ }
1301
+ try {
1302
+ const value: unknown = JSON.parse(
1303
+ fs.readFileSync(
1304
+ path.join(
1305
+ snapshotDirectory(base),
1306
+ `${KEY_BASELINE_PREFIX}${runId}.json`,
1307
+ ),
1308
+ "utf8",
1309
+ ),
1310
+ );
1311
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1312
+ return undefined;
1313
+ }
1314
+ const document = value as Record<string, unknown>;
1315
+ if (
1316
+ document.version !== SNAPSHOT_VERSION ||
1317
+ document.runId !== runId ||
1318
+ typeof document.inputs !== "object" ||
1319
+ document.inputs === null ||
1320
+ Array.isArray(document.inputs) ||
1321
+ Object.values(document.inputs).some(
1322
+ (entry) => !isWatchInputKeyBaseline(entry),
1323
+ ) ||
1324
+ !Array.isArray(document.staticInputs) ||
1325
+ document.staticInputs.some(
1326
+ (entry) =>
1327
+ typeof entry !== "string" ||
1328
+ !Object.prototype.hasOwnProperty.call(document.inputs, entry),
1329
+ )
1330
+ ) {
1331
+ return undefined;
1332
+ }
1333
+ return value as KeyBaselineDocument;
1334
+ } catch {
1335
+ return undefined;
1336
+ }
1337
+ }
1338
+
1339
+ /** Whether a run token is allowed to authorize a reusable static key. */
1340
+ function isReusableSnapshotRunId(runId: string | undefined): runId is string {
1341
+ return runId !== undefined && /^[a-f0-9]{32}$/.test(runId);
1342
+ }
1343
+
685
1344
  function snapshotDirectory(base: string): string {
686
1345
  return path.join(base, ...SNAPSHOT_DIRECTORY);
687
1346
  }
@@ -856,21 +1515,45 @@ function parseSnapshotDocument(text: string): SnapshotDocument | undefined {
856
1515
  return undefined;
857
1516
  }
858
1517
  const document = value as Record<string, unknown>;
859
- if (document.version !== SNAPSHOT_VERSION || !Array.isArray(document.files)) {
1518
+ const keys = Object.keys(document).sort();
1519
+ const expectedKeys = ["files", "tainted", "version", "volatile"];
1520
+ if (Object.prototype.hasOwnProperty.call(document, "id")) {
1521
+ expectedKeys.push("id");
1522
+ expectedKeys.sort();
1523
+ }
1524
+ if (
1525
+ stableStringify(keys) !== stableStringify(expectedKeys) ||
1526
+ document.version !== SNAPSHOT_VERSION ||
1527
+ !Array.isArray(document.files) ||
1528
+ document.files.some(
1529
+ (entry) =>
1530
+ typeof entry !== "string" ||
1531
+ !(path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry)),
1532
+ ) ||
1533
+ new Set(document.files).size !== document.files.length ||
1534
+ stableStringify(document.files) !==
1535
+ stableStringify([...document.files].sort()) ||
1536
+ typeof document.tainted !== "boolean" ||
1537
+ typeof document.volatile !== "boolean" ||
1538
+ (document.id !== undefined &&
1539
+ (typeof document.id !== "string" || !/^[a-f0-9]{32}$/.test(document.id)))
1540
+ ) {
860
1541
  return undefined;
861
1542
  }
862
1543
  return {
863
- files: document.files.filter(
864
- (entry): entry is string => typeof entry === "string",
865
- ),
1544
+ files: document.files as string[],
866
1545
  ...(typeof document.id === "string" ? { id: document.id } : {}),
1546
+ tainted: document.tainted,
867
1547
  version: SNAPSHOT_VERSION,
868
- volatile: document.volatile === true,
1548
+ volatile: document.volatile,
869
1549
  };
870
1550
  }
871
1551
 
872
1552
  /** Write a snapshot document atomically (unique temp file, then rename). */
873
- function writeSnapshotDocument(file: string, document: SnapshotDocument): void {
1553
+ function writeSnapshotDocument(
1554
+ file: string,
1555
+ document: SnapshotDocument | KeyBaselineDocument,
1556
+ ): void {
874
1557
  const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
875
1558
  try {
876
1559
  fs.writeFileSync(temp, JSON.stringify(document), "utf8");