@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.
@@ -21,20 +21,20 @@ var path = require('node:path');
21
21
  * `projectRoot` plus the resolved tsconfig's directory when it lies outside),
22
22
  * hashed with the exact walk universe the `@ttsc/unplugin` transform core
23
23
  * validates its own cache against.
24
- * - **Recorded out-of-walk inputs.** The transform core cannot walk files outside
25
- * the roots or under ignored directories, but the host-owned reference graph
26
- * (samchon/ttsc#718) reports them per transform. Workers record them into a
27
- * snapshot under `node_modules/.cache/ttsc-metro`; the next run's
28
- * `getCacheKey` re-hashes the recorded set.
24
+ * - **Recorded transform inputs.** The host-owned reference graph
25
+ * (samchon/ttsc#718) reports each transform's derived inputs. Workers retain
26
+ * them under `node_modules/.cache/ttsc-metro`, compare their generation state
27
+ * with the exact main-process key baseline, and batch one durable write per
28
+ * delivered module.
29
29
  *
30
30
  * Snapshot layout: one main file carrying a random epoch id plus per-worker
31
31
  * files with unique names, so concurrent workers never race a shared write.
32
- * `withTtsc` (the single config process, before workers exist) compacts worker
33
- * files into the main file. Readers take the union of every file, reading the
34
- * worker files strictly before the main file: the compactor renames the merged
35
- * main into place strictly before deleting a worker file, so a worker file that
36
- * disappears mid-read is always already merged into the main the reader loads
37
- * afterwards.
32
+ * `withTtsc` compacts worker files into the main file before its workers exist,
33
+ * under a process-shared lock for builds using the same project cache. Readers
34
+ * take the union of every file, reading the worker files strictly before the
35
+ * main file: the compactor renames the merged main into place strictly before
36
+ * deleting a worker file, so a worker file that disappears mid-read is always
37
+ * already merged into the main the reader loads afterwards.
38
38
  *
39
39
  * Sound degradations, by design:
40
40
  *
@@ -50,9 +50,12 @@ var path = require('node:path');
50
50
  * until a later run records the volatile declaration gone.
51
51
  * - A recorded input that disappears hashes as a stable `missing` marker, so
52
52
  * deletion and reappearance both move the key.
53
+ * - A worker state that differs from the static key's run baseline taints the
54
+ * observation; compaction rotates the epoch so even A -> B -> A cannot reuse
55
+ * output stored under the earlier A key.
53
56
  */
54
57
  /** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
55
- const SNAPSHOT_VERSION = 1;
58
+ const SNAPSHOT_VERSION = 2;
56
59
  /** Snapshot directory segments under the fingerprint base directory. */
57
60
  const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
58
61
  /** Recovery-document prefix in the parent cache directory. */
@@ -63,6 +66,14 @@ const MAIN_SNAPSHOT = "graph-inputs.json";
63
66
  const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
64
67
  /** Prefix used after a compactor atomically claims an immutable worker file. */
65
68
  const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
69
+ /** Directory lock serializing the one mutable main-snapshot rewrite. */
70
+ const SNAPSHOT_COMPACTION_LOCK = "snapshot-compaction.lock";
71
+ /** Complete owner record stored inside an atomically published lock. */
72
+ const SNAPSHOT_COMPACTION_OWNER = "owner.json";
73
+ /** Cache-key baseline file prefix, one immutable identity per Metro run. */
74
+ const KEY_BASELINE_PREFIX = "key-baseline-";
75
+ /** Run-token prefix that forces every transformer key to be non-reusable. */
76
+ const NON_REUSABLE_RUN_PREFIX = "nonce:";
66
77
  /** Bases whose latest observation is not yet durable in the main snapshot. */
67
78
  const unhealthySnapshots = new Set();
68
79
  /**
@@ -83,11 +94,15 @@ function resolveFingerprintBase(projectRoot) {
83
94
  * already inside the base walk (an explicit out-of-root `project`, or a
84
95
  * monorepo-root tsconfig discovered above the app). Matching the transform
85
96
  * core's own validation universe keeps the invariant simple: everything the
86
- * core treats as an input is fingerprinted, either by a walk here or by the
87
- * recorded out-of-walk snapshot.
97
+ * core treats as an input is fingerprinted by the walk, the recorded snapshot,
98
+ * or both.
88
99
  */
89
100
  function fingerprintRoots(base, explicitProject) {
90
- const tsconfig = resolveProjectTsconfig(base, explicitProject);
101
+ const explicit = normalizedExplicitProject(explicitProject);
102
+ return projectViewRoots(base, resolveProjectTsconfig(base, explicit), explicit);
103
+ }
104
+ /** Roots covered by one selected project's static walk. */
105
+ function projectViewRoots(base, tsconfig, explicitProject) {
91
106
  // Containment, not walk membership. The question here is whether the
92
107
  // tsconfig's directory already sits inside the subtree the base walk covers,
93
108
  // so that adding it would repeat the same walk. `isProjectWalkPath` answers a
@@ -95,13 +110,17 @@ function fingerprintRoots(base, explicitProject) {
95
110
  // stopped hashing files that cannot enter the program it began answering
96
111
  // `false` for every `tsconfig.json`, which returned the base twice and hashed
97
112
  // the whole project twice on every cache key (samchon/ttsc#1307).
113
+ const resolvedBase = path.resolve(base);
98
114
  const directory = path.dirname(path.resolve(tsconfig));
99
- const relative = path.relative(path.resolve(base), directory);
115
+ const relative = path.relative(resolvedBase, directory);
100
116
  const inside = relative === "" ||
101
117
  (relative !== ".." &&
102
118
  !relative.startsWith(`..${path.sep}`) &&
103
119
  !path.isAbsolute(relative));
104
- return inside ? [base] : [base, directory];
120
+ if (explicitProject === undefined && inside && directory !== resolvedBase) {
121
+ return [directory];
122
+ }
123
+ return inside ? [resolvedBase] : [resolvedBase, directory];
105
124
  }
106
125
  /**
107
126
  * Resolve one transform's project view, once, for every watch input it reports.
@@ -112,105 +131,204 @@ function fingerprintRoots(base, explicitProject) {
112
131
  */
113
132
  function resolveProjectView(props) {
114
133
  const base = resolveFingerprintBase(props.projectRoot);
115
- return {
134
+ const explicitProject = normalizedExplicitProject(props.explicitProject);
135
+ const start = explicitProject === undefined && props.filename !== undefined
136
+ ? path.dirname(path.resolve(props.filename))
137
+ : base;
138
+ const discovery = explicitProject === undefined
139
+ ? api.discoverNearestProjectTsconfig(start, props.projectDiscoveryFilesystem)
140
+ : undefined;
141
+ const tsconfig = discovery === undefined
142
+ ? resolveProjectTsconfig(start, explicitProject)
143
+ : (discovery.file ?? path.resolve(process.cwd(), "tsconfig.json"));
144
+ const discoveryInputs = discovery === undefined
145
+ ? [captureProjectDiscoveryInput(tsconfig)]
146
+ : discovery.candidates.map((candidate) => captureProjectDiscoveryInput(candidate.file, candidate.fileExists));
147
+ if (!discoveryInputs.some((input) => samePath(input.file, tsconfig))) {
148
+ discoveryInputs.push(captureProjectDiscoveryInput(tsconfig));
149
+ }
150
+ return createProjectView({
116
151
  base,
152
+ compilerOptions: props.compilerOptions,
153
+ discoveryInputs,
154
+ explicitProject,
155
+ tsconfig,
156
+ });
157
+ }
158
+ /** Create one cache view from an already selected project config. */
159
+ function createProjectView(props) {
160
+ const policy = membershipPolicy(props.tsconfig, props.compilerOptions);
161
+ return {
162
+ base: props.base,
163
+ discoveryInputs: props.discoveryInputs ?? [],
117
164
  explicitProject: props.explicitProject,
118
- policy: membershipPolicy(resolveProjectTsconfig(base, props.explicitProject), props.compilerOptions),
165
+ policy,
166
+ roots: projectViewRoots(props.base, props.tsconfig, props.explicitProject),
167
+ tsconfig: props.tsconfig,
168
+ walkPolicy: policy,
119
169
  };
120
170
  }
121
- /**
122
- * The membership policy of one project, memoized per resolved tsconfig and
123
- * caller overlay.
124
- *
125
- * Every use of the walk pair has to ask the same policy, or the two halves
126
- * disagree about the same project. The walk hashes what the configuration can
127
- * admit, and `isProjectWalkPath` answers whether the walk covers a path, so a
128
- * permissive answer here would claim coverage the walk does not provide and the
129
- * input would be recorded nowhere at all (samchon/ttsc#1307).
130
- */
131
- const MEMBERSHIP_POLICIES = new Map();
132
- function membershipPolicy(tsconfig, compilerOptions) {
133
- // Keyed by the config's path and the caller's overlay, and never trusted on
134
- // the key alone: a hit is served only while the config's own stamp still
135
- // matches. A Metro worker outlives many runs, and this is consulted once per
136
- // delivered file, so a memo that trusted its key would hold the policy a
137
- // project had when the worker started. An edit adding `exclude` would then
138
- // leave the worker judging a file in-walk while the next run's walk skipped
139
- // it, which is precisely the both-sides-disagree hole this policy exists to
140
- // close.
141
- //
142
- // The overlay belongs in the key rather than the stamp because it is part of
143
- // the question, not part of any file: keyed by path alone the memo would hand
144
- // a caller who passed `allowJs` the policy resolved for a caller who did not.
145
- const key = [tsconfig, stableStringify(compilerOptions ?? {})].join(String.fromCharCode(0));
146
- const existing = MEMBERSHIP_POLICIES.get(key);
147
- if (existing !== undefined && existing.stamp === stampOf(existing.sources)) {
148
- return existing.policy;
149
- }
150
- // The caller's compiler-options overlay wins for the compile, so it has to
151
- // win here too, exactly as it does in the adapter: a project given
152
- // `allowJs: true` through `withTtsc` has a wider program than its tsconfig
153
- // alone describes, and a narrower policy here would ask a different question
154
- // about the same project (samchon/ttsc#1316).
155
- const policy = api.mergeMembershipPolicyOverlay(api.readProjectMembershipPolicy(tsconfig), compilerOptions ?? {}, path.dirname(path.resolve(tsconfig)));
156
- // Stamp the whole `extends` chain, not the leaf. Adding `exclude` to a shared
157
- // `tsconfig.base.json` leaves the leaf's own mtime and size untouched while
158
- // changing every answer the policy gives, so a leaf-only stamp would keep the
159
- // worker on the pre-edit policy for its lifetime.
160
- const sources = policy.sources.length === 0 ? [tsconfig] : [...policy.sources];
161
- MEMBERSHIP_POLICIES.set(key, {
162
- policy,
163
- sources,
164
- stamp: stampOf(sources),
165
- });
166
- return policy;
171
+ /** Attach identity to the exact file predicate that selected a worker project. */
172
+ function captureProjectDiscoveryInput(file, selectedFileExists) {
173
+ const baseline = api.captureWatchInputFileBaseline(file);
174
+ if (baseline === undefined) {
175
+ return { file };
176
+ }
177
+ const fileExists = selectedFileExists ?? baseline.fileExists;
178
+ return {
179
+ evidence: {
180
+ identity: baseline.identity,
181
+ missing: !fileExists,
182
+ state: {
183
+ codec: "predicates",
184
+ observation: { fileExists },
185
+ },
186
+ unavailable: fileExists ? undefined : "not-file",
187
+ },
188
+ file,
189
+ };
167
190
  }
168
- /** A stamp over every config a policy was read from, in a stable order. */
169
- function stampOf(sources) {
170
- return sources
171
- .map((source) => {
172
- try {
173
- const stats = fs.statSync(source);
174
- // A directory occupying a candidate path can never be the config, so it
175
- // contributes its existence and not its modification time, which moves
176
- // whenever any child is added or removed (samchon/ttsc#1316).
177
- return stats.isDirectory()
178
- ? `${source}:directory`
179
- : `${source}:${stats.mtimeMs}:${stats.size}`;
180
- }
181
- catch {
182
- // Absent now. `readProjectMembershipPolicy` answers for that too, and
183
- // the policy must be re-asked once the config appears.
184
- return `${source}:absent`;
185
- }
186
- })
187
- .join("|");
191
+ /** Resolve one project policy from source rather than trusting metadata alone. */
192
+ function membershipPolicy(tsconfig, compilerOptions) {
193
+ // The caller overlay wins here exactly as it does for the compile. Re-reading
194
+ // source is deliberate: a long-lived worker cannot validate config contents
195
+ // from mtime and size, because a same-stamp rewrite is legal on coarse or
196
+ // restored filesystems. `resolveProjectView` runs once per delivered module,
197
+ // and its result is shared by the batched recorder.
198
+ return api.mergeMembershipPolicyOverlay(api.readProjectMembershipPolicy(tsconfig), compilerOptions ?? {}, path.dirname(path.resolve(tsconfig)));
188
199
  }
189
200
  /**
190
201
  * Locate the tsconfig governing the project, mirroring the transform core's
191
202
  * discovery: an explicit `project` resolves against the working directory;
192
203
  * otherwise ancestor directories starting at `base` are searched for a
193
- * `tsconfig.json`, falling back to `<base>/tsconfig.json`.
204
+ * `tsconfig.json` file, falling back to `<cwd>/tsconfig.json` like the shared
205
+ * transform core.
194
206
  */
195
207
  function resolveProjectTsconfig(base, explicitProject) {
196
- if (explicitProject !== undefined && explicitProject.length !== 0) {
208
+ if (explicitProject !== undefined) {
197
209
  return path.isAbsolute(explicitProject)
198
210
  ? explicitProject
199
211
  : path.resolve(process.cwd(), explicitProject);
200
212
  }
201
- let current = base;
202
- while (true) {
203
- const candidate = path.join(current, "tsconfig.json");
204
- if (fs.existsSync(candidate)) {
205
- return candidate;
206
- }
207
- const parent = path.dirname(current);
208
- if (parent === current) {
209
- break;
213
+ const discovered = api.findNearestProjectTsconfig(base);
214
+ if (discovered !== undefined) {
215
+ return discovered;
216
+ }
217
+ return path.resolve(process.cwd(), "tsconfig.json");
218
+ }
219
+ /** Empty project strings carry the same implicit meaning as omission. */
220
+ function normalizedExplicitProject(explicitProject) {
221
+ return explicitProject === undefined || explicitProject.length === 0
222
+ ? undefined
223
+ : explicitProject;
224
+ }
225
+ /** Resolve every implicit project whose files can be delivered below base. */
226
+ function fingerprintProjectViews(props) {
227
+ const primary = resolveProjectView(props);
228
+ if (primary.explicitProject !== undefined) {
229
+ return {
230
+ discoveryInputs: [primary.tsconfig],
231
+ projects: [stableFingerprintProjectView(primary, props.compilerOptions)],
232
+ };
233
+ }
234
+ const firstMap = api.findProjectTsconfigs(primary.base, props.projectDiscoveryFilesystem);
235
+ if (!firstMap.complete) {
236
+ throw new Error("Unable to enumerate Metro's implicit TypeScript projects.");
237
+ }
238
+ const projects = [
239
+ stableFingerprintProjectView(primary, props.compilerOptions),
240
+ ];
241
+ for (const tsconfig of firstMap.files) {
242
+ const resolved = path.resolve(tsconfig);
243
+ if (projects.some((project) => samePath(project.tsconfig, resolved))) {
244
+ continue;
210
245
  }
211
- current = parent;
246
+ projects.push(stableFingerprintProjectView(createProjectView({
247
+ base: primary.base,
248
+ compilerOptions: props.compilerOptions,
249
+ explicitProject: undefined,
250
+ tsconfig: resolved,
251
+ }), props.compilerOptions));
252
+ }
253
+ const secondMap = api.findProjectTsconfigs(primary.base, props.projectDiscoveryFilesystem);
254
+ const selectedAfter = resolveProjectView(props);
255
+ if (!secondMap.complete ||
256
+ !sameProjectMap(firstMap.files, secondMap.files) ||
257
+ !sameProjectMap(firstMap.candidates, secondMap.candidates) ||
258
+ !samePath(primary.tsconfig, selectedAfter.tsconfig) ||
259
+ stableStringify(primary.discoveryInputs) !==
260
+ stableStringify(selectedAfter.discoveryInputs) ||
261
+ projects.some((project) => stableStringify(api.readTsconfigSourceSnapshot(project.tsconfig)) !==
262
+ stableStringify(project.configSources))) {
263
+ throw new Error("Metro's implicit TypeScript project map changed.");
212
264
  }
213
- return path.resolve(base, "tsconfig.json");
265
+ const routedRoots = projects.map((project) => project.roots[0]);
266
+ return {
267
+ // `findProjectTsconfigs` covers candidates at and below Metro's base. The
268
+ // primary nearest-config search can also cross above that base to a
269
+ // monorepo config, and every rejected candidate on that ancestor path is
270
+ // just as capable of changing the selected project. Keep both sets in the
271
+ // main-process baseline so a worker does not taint every unchanged run for
272
+ // reporting a candidate the static key itself used.
273
+ discoveryInputs: [
274
+ ...new Set([
275
+ ...primary.discoveryInputs.map((input) => input.file),
276
+ ...firstMap.candidates,
277
+ ]),
278
+ ].sort(),
279
+ projects: projects.map((project, index) => {
280
+ const root = routedRoots[index];
281
+ const nestedRoots = routedRoots.filter((candidate, candidateIndex) => candidateIndex !== index &&
282
+ !samePath(candidate, root) &&
283
+ pathIsWithin(candidate, root));
284
+ return nestedRoots.length === 0
285
+ ? project
286
+ : {
287
+ ...project,
288
+ walkPolicy: {
289
+ ...project.policy,
290
+ excludedDirectories: [
291
+ ...project.policy.excludedDirectories,
292
+ ...nestedRoots,
293
+ ],
294
+ },
295
+ };
296
+ }),
297
+ };
298
+ }
299
+ /** Read one implicit project's policy between equal complete config snapshots. */
300
+ function stableFingerprintProjectView(project, compilerOptions) {
301
+ const before = api.readTsconfigSourceSnapshot(project.tsconfig);
302
+ const refreshed = createProjectView({
303
+ base: project.base,
304
+ compilerOptions,
305
+ explicitProject: project.explicitProject,
306
+ tsconfig: project.tsconfig,
307
+ });
308
+ const after = api.readTsconfigSourceSnapshot(project.tsconfig);
309
+ if (before.some((entry) => entry.contents === null) ||
310
+ after.some((entry) => entry.contents === null) ||
311
+ stableStringify(before) !== stableStringify(after)) {
312
+ throw new Error("Unable to read a stable TypeScript project config graph.");
313
+ }
314
+ return { ...refreshed, configSources: after };
315
+ }
316
+ /** Whether two complete lexical config enumerations name the same paths. */
317
+ function sameProjectMap(left, right) {
318
+ return (left.length === right.length &&
319
+ left.every((entry, index) => samePath(entry, right[index])));
320
+ }
321
+ /** Host-platform equality for two resolved path spellings. */
322
+ function samePath(left, right) {
323
+ return path.relative(path.resolve(left), path.resolve(right)) === "";
324
+ }
325
+ /** Whether one resolved path lies at or below another. */
326
+ function pathIsWithin(child, parent) {
327
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
328
+ return (relative === "" ||
329
+ (relative !== ".." &&
330
+ !relative.startsWith(`..${path.sep}`) &&
331
+ !path.isAbsolute(relative)));
214
332
  }
215
333
  /**
216
334
  * Compute the fingerprint `getCacheKey` folds into Metro's static transformer
@@ -220,80 +338,183 @@ function resolveProjectTsconfig(base, explicitProject) {
220
338
  function computeProjectFingerprint(props) {
221
339
  try {
222
340
  const base = resolveFingerprintBase(props.projectRoot);
223
- const hash = node_crypto.createHash("sha256");
224
- // Judge the fingerprint's walk by the same configuration the compile
225
- // does. Metro folds this into one static key, so an entry the program
226
- // could never contain used to re-key every transformed file rather than
227
- // costing one compile the way it does for a bundler (samchon/ttsc#1307).
228
- //
229
- // The caller's compiler-options overlay is part of that configuration, and
230
- // has to reach the walk as well as the recorder. The two are the halves of
231
- // one cache key and run in different processes, so they agree only by
232
- // deriving from the same declared options: a walk resolved without the
233
- // overlay while the recorder resolves with it leaves an overlay-admitted
234
- // input in neither half, which is the both-sides-disagree hole in its
235
- // quietest form (samchon/ttsc#1316).
236
- const project = resolveProjectView({
237
- compilerOptions: props.compilerOptions,
238
- explicitProject: props.explicitProject,
239
- projectRoot: props.projectRoot,
240
- });
241
- for (const root of fingerprintRoots(base, props.explicitProject)) {
242
- hash.update(stableStringify(api.collectProjectInputHashes(root, undefined, undefined, project.policy)));
243
- }
244
- const snapshot = readSnapshotState(base);
245
- if (snapshot === undefined || snapshot.volatile) {
246
- hash.update(nonce());
341
+ const before = observeProjectFingerprint(props);
342
+ const after = observeProjectFingerprint(props);
343
+ if (stableStringify(before) !== stableStringify(after)) {
344
+ throw new Error("Metro's project fingerprint changed while observed.");
247
345
  }
248
- else {
249
- hash.update(`snapshot:${snapshot.id}`);
250
- hash.update(stableStringify(api.collectExternalInputHashes(snapshot.files)));
346
+ if (props.runId !== undefined) {
347
+ writeKeyBaseline(base, props.runId, after.inputs, after.staticInputs);
251
348
  }
349
+ const hash = node_crypto.createHash("sha256");
350
+ hash.update(stableStringify(after.fingerprint));
252
351
  return hash.digest("hex");
253
352
  }
254
353
  catch {
255
354
  return nonce();
256
355
  }
257
356
  }
357
+ /** Build the complete value hashed by one static key. */
358
+ function observeProjectFingerprint(props) {
359
+ // Judge the fingerprint's walk by the same configuration the compile does.
360
+ // The caller overlay reaches this walk and the worker through the same
361
+ // serialized options, so neither side can silently describe another program.
362
+ const projectMap = fingerprintProjectViews(props);
363
+ const inputs = {};
364
+ const staticInputs = new Set();
365
+ const configSources = new Map();
366
+ const projectFingerprints = [];
367
+ for (const candidate of projectMap.discoveryInputs) {
368
+ addDiscoveryBaselineInput(inputs, candidate, staticInputs);
369
+ }
370
+ for (const project of projectMap.projects) {
371
+ for (const source of project.configSources) {
372
+ const existing = configSources.get(source.path);
373
+ if (existing !== undefined && existing.contents !== source.contents) {
374
+ throw new Error("A TypeScript config changed during fingerprinting.");
375
+ }
376
+ const baseline = addBaselineInput(inputs, source.path, staticInputs);
377
+ configSources.set(source.path, {
378
+ contents: source.contents,
379
+ identity: baseline.identity,
380
+ });
381
+ }
382
+ for (const root of project.roots) {
383
+ const snapshot = api.collectProjectInputHashSnapshot(root, undefined, undefined, project.walkPolicy);
384
+ if (!snapshot.complete) {
385
+ throw new Error("Unable to read a complete Metro project walk.");
386
+ }
387
+ const fingerprintedInputs = {};
388
+ for (const [key, expected] of Object.entries(snapshot.hashes)) {
389
+ const file = path.resolve(root, key);
390
+ const baseline = addBaselineInput(inputs, file, staticInputs);
391
+ if (baseline.hostHash !== expected) {
392
+ throw new Error("A Metro project input changed while fingerprinted.");
393
+ }
394
+ fingerprintedInputs[key] = {
395
+ hash: expected,
396
+ identity: baseline.identity,
397
+ };
398
+ }
399
+ projectFingerprints.push({
400
+ inputs: fingerprintedInputs,
401
+ root,
402
+ tsconfig: project.tsconfig,
403
+ });
404
+ }
405
+ }
406
+ const snapshot = readSnapshotState(resolveFingerprintBase(props.projectRoot));
407
+ if (snapshot === undefined || snapshot.volatile || snapshot.tainted) {
408
+ throw new Error("Metro's recorded transform snapshot is not reusable.");
409
+ }
410
+ const recorded = {};
411
+ for (const file of snapshot.files) {
412
+ const baseline = addBaselineInput(inputs, file);
413
+ recorded[snapshotPathKey(file)] = {
414
+ hash: baseline.hostHash,
415
+ identity: baseline.identity,
416
+ };
417
+ }
418
+ return {
419
+ fingerprint: {
420
+ configSources: [...configSources].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0),
421
+ projects: projectFingerprints,
422
+ snapshot: { id: snapshot.id, inputs: recorded },
423
+ },
424
+ inputs,
425
+ staticInputs: [...staticInputs].sort(),
426
+ };
427
+ }
428
+ /** Add one lexical path's stable broad state to a key baseline. */
429
+ function addBaselineInput(inputs, file, staticInputs) {
430
+ const key = snapshotPathKey(file);
431
+ const observed = api.captureWatchInputBaseline(file);
432
+ if (observed === undefined) {
433
+ throw new Error("Unable to read a stable Metro input baseline.");
434
+ }
435
+ const existing = inputs[key];
436
+ if (existing !== undefined) {
437
+ if (existing.identity !== observed.identity ||
438
+ existing.fileExists !== observed.fileExists ||
439
+ ("hostHash" in existing &&
440
+ stableStringify(existing) !== stableStringify(observed))) {
441
+ throw new Error("A Metro input changed between baseline observations.");
442
+ }
443
+ inputs[key] = { ...existing, ...observed };
444
+ }
445
+ else {
446
+ inputs[key] = observed;
447
+ }
448
+ staticInputs?.add(key);
449
+ return observed;
450
+ }
451
+ /** Add the stable file predicate used by the project-map traversal. */
452
+ function addDiscoveryBaselineInput(inputs, file, staticInputs) {
453
+ const key = snapshotPathKey(file);
454
+ const observed = api.captureWatchInputFileBaseline(file);
455
+ if (observed === undefined) {
456
+ throw new Error("Unable to read a stable Metro project candidate.");
457
+ }
458
+ const existing = inputs[key];
459
+ if (existing !== undefined &&
460
+ (existing.identity !== observed.identity ||
461
+ existing.fileExists !== observed.fileExists)) {
462
+ throw new Error("A Metro project candidate changed while observed.");
463
+ }
464
+ inputs[key] = existing ?? observed;
465
+ staticInputs.add(key);
466
+ }
258
467
  /**
259
468
  * A value no other run can reproduce. Folding it means this run's cache entries
260
469
  * are written but never reused by later runs, and this run reuses nothing from
261
- * earlier ones — the sound fallback whenever the recorded out-of-walk input set
470
+ * earlier ones — the sound fallback whenever the recorded transform input set
262
471
  * is unknown or unrepresentable.
263
472
  */
264
473
  function nonce() {
265
474
  return `nonce:${node_crypto.randomBytes(32).toString("hex")}`;
266
475
  }
267
476
  /**
268
- * Prepare the snapshot for a new run. Called from `withTtsc` in the single
269
- * Metro config process, before any worker exists: creates the main snapshot
270
- * (fresh epoch id) when missing or corrupt, compacts leftover worker files into
271
- * it, and sweeps unparseable worker files plus crash-leftover temp files. An
272
- * unparseable worker file's recordings are unrecoverable, so its removal mints
273
- * a fresh epoch id every key that might have depended on the lost recordings
274
- * is soundly orphaned, and later runs stabilize instead of degrading to a nonce
275
- * forever. A failed rewrite leaves a recovery document outside the snapshot
276
- * directory so `getCacheKey` degrades to a nonce until a later compaction
277
- * succeeds. If an older readable main exists and neither location is writable,
278
- * preparation throws instead of authorizing stale reuse.
477
+ * Prepare the snapshot for a new run. Called from `withTtsc` before any worker
478
+ * exists: creates the main snapshot (fresh epoch id) when missing or corrupt,
479
+ * compacts leftover worker files into it, and sweeps unparseable worker files
480
+ * plus crash-leftover temp files. Concurrent config processes are serialized; a
481
+ * contender takes a non-reusable run token instead of racing the mutable main
482
+ * rewrite. An unparseable worker file's recordings are unrecoverable, so its
483
+ * removal mints a fresh epoch id every key that might have depended on the
484
+ * lost recordings is soundly orphaned, and later runs stabilize instead of
485
+ * degrading to a nonce forever. A failed rewrite leaves a recovery document
486
+ * outside the snapshot directory and returns a non-reusable run token. The
487
+ * token crosses bundle and process boundaries, so `getCacheKey` degrades to a
488
+ * nonce even when neither snapshot location can persist the failure and an old
489
+ * main later reappears.
279
490
  */
280
491
  function prepareSnapshot(projectRoot) {
281
492
  const base = resolveFingerprintBase(projectRoot);
282
- let hadReadableMain = false;
493
+ const runId = node_crypto.randomBytes(16).toString("hex");
494
+ let reusable = true;
283
495
  let pending = {
284
496
  files: [],
497
+ tainted: false,
285
498
  version: SNAPSHOT_VERSION,
286
499
  volatile: false,
287
500
  };
501
+ let releaseCompactionLock;
288
502
  try {
289
503
  // A nonexistent base can never be a working Metro setup (Metro verifies
290
504
  // the project root exists), so preparing a snapshot there would only
291
505
  // materialize directory trees at arbitrary paths.
292
506
  if (!fs.existsSync(base)) {
293
- return;
507
+ return runId;
294
508
  }
295
509
  const directory = snapshotDirectory(base);
296
510
  fs.mkdirSync(directory, { recursive: true });
511
+ releaseCompactionLock = acquireSnapshotCompactionLock(directory);
512
+ if (releaseCompactionLock === undefined) {
513
+ // Another Metro config process is already rewriting the mutable main
514
+ // snapshot. It owns all pending worker documents, while this run takes a
515
+ // private nonce and therefore cannot reuse or publish under a stale key.
516
+ return `${NON_REUSABLE_RUN_PREFIX}${runId}`;
517
+ }
297
518
  // Read the worker files strictly before the main file (see the module doc
298
519
  // comment): a concurrent compactor deletes a worker file only after the
299
520
  // merged main is renamed into place, so whatever this enumeration misses
@@ -305,9 +526,9 @@ function prepareSnapshot(projectRoot) {
305
526
  throw new Error("Unable to enumerate Metro snapshot state.");
306
527
  }
307
528
  const main = readMainDocument(directory);
308
- hadReadableMain = main !== undefined && typeof main.id === "string";
309
529
  const files = new Set(main?.files ?? []);
310
530
  const observations = [...recovery.entries, ...workers.entries];
531
+ const tainted = observations.some((entry) => entry.tainted);
311
532
  const volatile = observations.length === 0
312
533
  ? (main?.volatile ?? false)
313
534
  : // Worker files carry the previous run's fresh observations, so they
@@ -324,9 +545,10 @@ function prepareSnapshot(projectRoot) {
324
545
  recovery.corruptPaths.length !== 0;
325
546
  pending = {
326
547
  files: [...files].sort(),
327
- id: !recovering && workers.corruptPaths.length === 0
548
+ id: !recovering && workers.corruptPaths.length === 0 && !tainted
328
549
  ? (main?.id ?? node_crypto.randomBytes(16).toString("hex"))
329
550
  : node_crypto.randomBytes(16).toString("hex"),
551
+ tainted: false,
330
552
  version: SNAPSHOT_VERSION,
331
553
  volatile,
332
554
  };
@@ -337,6 +559,7 @@ function prepareSnapshot(projectRoot) {
337
559
  ...recovery.paths,
338
560
  ...recovery.corruptPaths,
339
561
  ...listTemporaryFiles(directory),
562
+ ...listExpiredKeyBaselines(directory),
340
563
  ]) {
341
564
  try {
342
565
  fs.rmSync(file, { force: true });
@@ -353,16 +576,133 @@ function prepareSnapshot(projectRoot) {
353
576
  unhealthySnapshots.delete(base);
354
577
  }
355
578
  }
356
- catch (snapshotError) {
579
+ catch {
580
+ reusable = false;
357
581
  try {
358
582
  persistUnhealthySnapshot(base, pending);
359
583
  }
360
- catch (recoveryError) {
361
- if (hadReadableMain || hasReadableMainSnapshot(base)) {
362
- throw new AggregateError([snapshotError, recoveryError], "Unable to persist Metro snapshot state or its recovery record.");
584
+ catch {
585
+ // The returned token carries the failure when neither on-disk location
586
+ // can. No consumer may turn that token into a reusable cache key.
587
+ }
588
+ }
589
+ finally {
590
+ if (releaseCompactionLock !== undefined) {
591
+ try {
592
+ releaseCompactionLock();
593
+ }
594
+ catch {
595
+ reusable = false;
596
+ try {
597
+ persistUnhealthySnapshot(base, pending);
598
+ }
599
+ catch {
600
+ // The returned non-reusable token remains the final safety boundary.
601
+ }
363
602
  }
364
603
  }
365
604
  }
605
+ return reusable ? runId : `${NON_REUSABLE_RUN_PREFIX}${runId}`;
606
+ }
607
+ /**
608
+ * Acquire the process-shared lock for the mutable main snapshot.
609
+ *
610
+ * A complete owner directory is published atomically, and no process waits
611
+ * while holding a reusable run identity. A contending process therefore
612
+ * degrades immediately to a nonce. The owner retires the directory atomically
613
+ * after every success or failure path has persisted its verdict.
614
+ */
615
+ function acquireSnapshotCompactionLock(directory) {
616
+ const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
617
+ const token = node_crypto.randomBytes(16).toString("hex");
618
+ const candidate = path.join(directory, `.snapshot-compaction-${process.pid.toString(36)}-${token}`);
619
+ let candidateCreated = false;
620
+ try {
621
+ fs.mkdirSync(candidate);
622
+ candidateCreated = true;
623
+ fs.writeFileSync(path.join(candidate, SNAPSHOT_COMPACTION_OWNER), JSON.stringify({ pid: process.pid, token }), "utf8");
624
+ if (fs.existsSync(lock)) {
625
+ reapDeadSnapshotCompactionLock(lock);
626
+ return undefined;
627
+ }
628
+ fs.renameSync(candidate, lock);
629
+ candidateCreated = false;
630
+ }
631
+ catch (error) {
632
+ if (fs.existsSync(lock)) {
633
+ reapDeadSnapshotCompactionLock(lock);
634
+ return undefined;
635
+ }
636
+ throw error;
637
+ }
638
+ finally {
639
+ if (candidateCreated) {
640
+ fs.rmSync(candidate, { force: true, recursive: true });
641
+ }
642
+ }
643
+ return () => {
644
+ const owner = readSnapshotCompactionOwner(lock);
645
+ if (owner?.pid !== process.pid || owner.token !== token) {
646
+ throw new Error("Metro snapshot compaction lock ownership changed.");
647
+ }
648
+ const retired = path.join(directory, `.snapshot-compaction-released-${token}`);
649
+ fs.renameSync(lock, retired);
650
+ try {
651
+ fs.rmSync(retired, { force: true, recursive: true });
652
+ }
653
+ catch {
654
+ // A retired owner cannot block or be confused with the fixed lock name.
655
+ }
656
+ };
657
+ }
658
+ /** Read a complete lock owner; malformed state remains a conservative lock. */
659
+ function readSnapshotCompactionOwner(lock) {
660
+ try {
661
+ const value = JSON.parse(fs.readFileSync(path.join(lock, SNAPSHOT_COMPACTION_OWNER), "utf8"));
662
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
663
+ return undefined;
664
+ }
665
+ const owner = value;
666
+ return Number.isSafeInteger(owner.pid) &&
667
+ owner.pid > 0 &&
668
+ typeof owner.token === "string" &&
669
+ /^[a-f0-9]{32}$/.test(owner.token)
670
+ ? { pid: owner.pid, token: owner.token }
671
+ : undefined;
672
+ }
673
+ catch {
674
+ return undefined;
675
+ }
676
+ }
677
+ /**
678
+ * Remove a lock whose recorded process is proven dead.
679
+ *
680
+ * The dead owner's random token also names its quarantine. At most one
681
+ * contender can move the fixed lock there. The quarantine remains as a tiny
682
+ * election record, so a delayed contender can never move a newer owner's lock
683
+ * after the first contender has recovered the fixed name.
684
+ */
685
+ function reapDeadSnapshotCompactionLock(lock) {
686
+ const owner = readSnapshotCompactionOwner(lock);
687
+ if (owner === undefined || processIsAlive(owner.pid))
688
+ return;
689
+ const quarantine = path.join(path.dirname(lock), `.snapshot-compaction-stale-${owner.token}`);
690
+ try {
691
+ fs.renameSync(lock, quarantine);
692
+ }
693
+ catch {
694
+ // Another contender recovered this owner, or the lock changed after read.
695
+ }
696
+ }
697
+ /** Treat every process-query failure except a definite missing PID as live. */
698
+ function processIsAlive(pid) {
699
+ try {
700
+ process.kill(pid, 0);
701
+ return true;
702
+ }
703
+ catch (error) {
704
+ return error.code !== "ESRCH";
705
+ }
366
706
  }
367
707
  /**
368
708
  * Crash-leftover temp files from the atomic writer, swept at compaction. Only
@@ -390,6 +730,27 @@ function listTemporaryFiles(directory) {
390
730
  return [];
391
731
  }
392
732
  }
733
+ /** Old run baselines that cannot belong to an ordinary live Metro session. */
734
+ function listExpiredKeyBaselines(directory) {
735
+ const horizon = Date.now() - 7 * 24 * 60 * 60 * 1000;
736
+ try {
737
+ return fs
738
+ .readdirSync(directory)
739
+ .filter((name) => name.startsWith(KEY_BASELINE_PREFIX) && name.endsWith(".json"))
740
+ .map((name) => path.join(directory, name))
741
+ .filter((file) => {
742
+ try {
743
+ return fs.statSync(file).mtimeMs < horizon;
744
+ }
745
+ catch {
746
+ return false;
747
+ }
748
+ });
749
+ }
750
+ catch {
751
+ return [];
752
+ }
753
+ }
393
754
  /**
394
755
  * Read the unioned snapshot state, or `undefined` when the main snapshot is
395
756
  * missing or any snapshot file is corrupt (a torn or foreign write means the
@@ -417,27 +778,62 @@ function readSnapshotState(base) {
417
778
  }
418
779
  const files = new Set(main.files);
419
780
  let volatile = main.volatile;
781
+ let tainted = main.tainted;
420
782
  for (const entry of workers.entries) {
421
783
  for (const file of entry.files) {
422
784
  files.add(file);
423
785
  }
424
786
  volatile ||= entry.volatile;
787
+ tainted ||= entry.tainted;
425
788
  }
426
- return { files: [...files].sort(), id: main.id, volatile };
789
+ return { files: [...files].sort(), id: main.id, tainted, volatile };
427
790
  }
428
791
  /**
429
- * Recorder held by each Metro worker. It persists out-of-walk watch inputs and
430
- * missing in-walk paths delivered through the transform core's `addWatchFile`
431
- * hook, plus any volatile declaration. Existing in-walk files stay covered by
432
- * the project walk; a missing path must be retained because its creation is a
433
- * state change that the initial walk could not hash. A clean in-walk transform
434
- * also writes a document so it can clear a volatile declaration from an earlier
435
- * run. The unique name makes worker writes race-free; `withTtsc` compacts the
436
- * files on the next run.
792
+ * Recorder held by each Metro worker. It persists every derived watch input and
793
+ * any volatile declaration, compares compiler-generation evidence with the
794
+ * matching main-process run baseline, and marks any temporal mismatch tainted.
795
+ * A clean transform also writes a document so it can clear a volatile
796
+ * declaration from an earlier run. One cumulative document is flushed per
797
+ * delivered module; the unique name makes worker writes race-free, and
798
+ * `withTtsc` compacts the files on the next run.
437
799
  */
438
- function createSnapshotRecorder() {
800
+ function createSnapshotRecorder(runId) {
439
801
  const suffix = `${process.pid.toString(36)}-${node_crypto.randomBytes(6).toString("hex")}`;
440
802
  const states = new Map();
803
+ const baselines = new Map();
804
+ const baselineStaticInputs = new Map();
805
+ function keyBaselineCoverage(input, base) {
806
+ // A direct recorder without the private run handshake cannot prove that
807
+ // any input belongs to the main process's static key. Retain every path
808
+ // without claiming a temporal mismatch. Production always receives a run
809
+ // id from `withTtsc`; an absent or unreadable matching baseline then fails
810
+ // closed.
811
+ if (runId === undefined) {
812
+ return { matches: true, static: false };
813
+ }
814
+ let baseline = baselines.get(base);
815
+ if (baseline === undefined) {
816
+ baseline = readKeyBaseline(base, runId) ?? null;
817
+ baselines.set(base, baseline);
818
+ baselineStaticInputs.set(base, new Set(baseline?.staticInputs ?? []));
819
+ }
820
+ const key = snapshotPathKey(input.file);
821
+ const expected = baseline?.inputs[key];
822
+ try {
823
+ const matches = expected !== undefined &&
824
+ input.evidence !== undefined &&
825
+ api.watchInputEvidenceMatchesBaseline(input.evidence, expected);
826
+ return {
827
+ matches,
828
+ static: matches &&
829
+ baseline !== null &&
830
+ baselineStaticInputs.get(base)?.has(key) === true,
831
+ };
832
+ }
833
+ catch {
834
+ return { matches: false, static: false };
835
+ }
836
+ }
441
837
  function stateFor(project) {
442
838
  const base = project.base;
443
839
  let state = states.get(base);
@@ -446,7 +842,7 @@ function createSnapshotRecorder() {
446
842
  dirty: false,
447
843
  files: new Set(),
448
844
  observed: false,
449
- roots: fingerprintRoots(base, project.explicitProject),
845
+ tainted: false,
450
846
  volatile: false,
451
847
  };
452
848
  states.set(base, state);
@@ -459,6 +855,7 @@ function createSnapshotRecorder() {
459
855
  }
460
856
  const document = {
461
857
  files: [...state.files].sort(),
858
+ tainted: state.tainted,
462
859
  version: SNAPSHOT_VERSION,
463
860
  volatile: state.volatile,
464
861
  };
@@ -475,35 +872,47 @@ function createSnapshotRecorder() {
475
872
  persistUnhealthySnapshot(base, document);
476
873
  }
477
874
  catch (recoveryError) {
478
- if (hasReadableMainSnapshot(base)) {
875
+ // A reusable run id proves that the main process authorized a cache
876
+ // key. The explicit nonce token is different: it guarantees that this
877
+ // run's output cannot be reused, so losing its observation is safe.
878
+ if (isReusableSnapshotRunId(runId) ||
879
+ (runId === undefined && hasReadableMainSnapshot(base))) {
479
880
  throw new AggregateError([snapshotError, recoveryError], "Unable to persist a Metro snapshot observation or its recovery record.");
480
881
  }
481
882
  }
482
883
  }
483
884
  }
484
- return {
485
- record(props) {
486
- const base = props.project.base;
487
- const state = stateFor(props.project);
488
- const input = path.resolve(props.input);
489
- const firstObservation = !state.observed;
490
- state.observed = true;
491
- if (state.files.has(input) ||
492
- (fs.existsSync(input) &&
493
- state.roots.some((root) => api.isProjectWalkPath(root, input, undefined, undefined, props.project.policy)))) {
494
- // Even when every input belongs to the project walk, the worker must
495
- // publish that it performed a clean transform. Otherwise an old main
496
- // snapshot with `volatile: true` remains sticky forever.
497
- if (firstObservation || state.dirty) {
498
- state.dirty = true;
499
- flush(base, state);
500
- }
501
- return;
885
+ function recordMany(props) {
886
+ const base = props.project.base;
887
+ const state = stateFor(props.project);
888
+ const firstObservation = !state.observed;
889
+ state.observed = true;
890
+ for (const input of props.inputs) {
891
+ const file = path.resolve(input.file);
892
+ const coverage = keyBaselineCoverage({ ...input, file }, base);
893
+ if (!coverage.matches) {
894
+ state.tainted = true;
895
+ }
896
+ if (!coverage.static && !state.files.has(file)) {
897
+ state.files.add(file);
898
+ state.dirty = true;
502
899
  }
503
- state.files.add(input);
900
+ }
901
+ // A clean empty delivery must still clear a volatile verdict from the
902
+ // preceding run. Persist once for the whole module, not once per input.
903
+ if (firstObservation || state.tainted) {
504
904
  state.dirty = true;
505
- flush(base, state);
905
+ }
906
+ flush(base, state);
907
+ }
908
+ return {
909
+ record(props) {
910
+ recordMany({
911
+ inputs: [{ file: props.input }],
912
+ project: props.project,
913
+ });
506
914
  },
915
+ recordMany,
507
916
  recordVolatile(props) {
508
917
  const base = props.project.base;
509
918
  const state = stateFor(props.project);
@@ -517,6 +926,65 @@ function createSnapshotRecorder() {
517
926
  },
518
927
  };
519
928
  }
929
+ /** Filesystem-keyed lexical spelling used by main and worker processes. */
930
+ function snapshotPathKey(file) {
931
+ const resolved = path.resolve(file);
932
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
933
+ }
934
+ /** Persist the exact filesystem state one run's static key observed. */
935
+ function writeKeyBaseline(base, runId, inputs, staticInputs) {
936
+ if (!isReusableSnapshotRunId(runId)) {
937
+ throw new Error("Invalid Metro snapshot run identity.");
938
+ }
939
+ const file = path.join(snapshotDirectory(base), `${KEY_BASELINE_PREFIX}${runId}.json`);
940
+ if (fs.existsSync(file)) {
941
+ const existing = readKeyBaseline(base, runId);
942
+ if (existing === undefined ||
943
+ stableStringify(existing.inputs) !== stableStringify(inputs) ||
944
+ stableStringify(existing.staticInputs) !== stableStringify(staticInputs)) {
945
+ throw new Error("A Metro run attempted to replace its key baseline.");
946
+ }
947
+ return;
948
+ }
949
+ writeSnapshotDocument(file, {
950
+ inputs,
951
+ runId,
952
+ staticInputs,
953
+ version: SNAPSHOT_VERSION,
954
+ });
955
+ }
956
+ /** Read only the immutable baseline belonging to this worker's run. */
957
+ function readKeyBaseline(base, runId) {
958
+ if (!isReusableSnapshotRunId(runId)) {
959
+ return undefined;
960
+ }
961
+ try {
962
+ const value = JSON.parse(fs.readFileSync(path.join(snapshotDirectory(base), `${KEY_BASELINE_PREFIX}${runId}.json`), "utf8"));
963
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
964
+ return undefined;
965
+ }
966
+ const document = value;
967
+ if (document.version !== SNAPSHOT_VERSION ||
968
+ document.runId !== runId ||
969
+ typeof document.inputs !== "object" ||
970
+ document.inputs === null ||
971
+ Array.isArray(document.inputs) ||
972
+ Object.values(document.inputs).some((entry) => !api.isWatchInputKeyBaseline(entry)) ||
973
+ !Array.isArray(document.staticInputs) ||
974
+ document.staticInputs.some((entry) => typeof entry !== "string" ||
975
+ !Object.prototype.hasOwnProperty.call(document.inputs, entry))) {
976
+ return undefined;
977
+ }
978
+ return value;
979
+ }
980
+ catch {
981
+ return undefined;
982
+ }
983
+ }
984
+ /** Whether a run token is allowed to authorize a reusable static key. */
985
+ function isReusableSnapshotRunId(runId) {
986
+ return runId !== undefined && /^[a-f0-9]{32}$/.test(runId);
987
+ }
520
988
  function snapshotDirectory(base) {
521
989
  return path.join(base, ...SNAPSHOT_DIRECTORY);
522
990
  }
@@ -656,14 +1124,32 @@ function parseSnapshotDocument(text) {
656
1124
  return undefined;
657
1125
  }
658
1126
  const document = value;
659
- if (document.version !== SNAPSHOT_VERSION || !Array.isArray(document.files)) {
1127
+ const keys = Object.keys(document).sort();
1128
+ const expectedKeys = ["files", "tainted", "version", "volatile"];
1129
+ if (Object.prototype.hasOwnProperty.call(document, "id")) {
1130
+ expectedKeys.push("id");
1131
+ expectedKeys.sort();
1132
+ }
1133
+ if (stableStringify(keys) !== stableStringify(expectedKeys) ||
1134
+ document.version !== SNAPSHOT_VERSION ||
1135
+ !Array.isArray(document.files) ||
1136
+ document.files.some((entry) => typeof entry !== "string" ||
1137
+ !(path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry))) ||
1138
+ new Set(document.files).size !== document.files.length ||
1139
+ stableStringify(document.files) !==
1140
+ stableStringify([...document.files].sort()) ||
1141
+ typeof document.tainted !== "boolean" ||
1142
+ typeof document.volatile !== "boolean" ||
1143
+ (document.id !== undefined &&
1144
+ (typeof document.id !== "string" || !/^[a-f0-9]{32}$/.test(document.id)))) {
660
1145
  return undefined;
661
1146
  }
662
1147
  return {
663
- files: document.files.filter((entry) => typeof entry === "string"),
1148
+ files: document.files,
664
1149
  ...(typeof document.id === "string" ? { id: document.id } : {}),
1150
+ tainted: document.tainted,
665
1151
  version: SNAPSHOT_VERSION,
666
- volatile: document.volatile === true,
1152
+ volatile: document.volatile,
667
1153
  };
668
1154
  }
669
1155
  /** Write a snapshot document atomically (unique temp file, then rename). */