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