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