@ttsc/metro 0.30.4 → 0.31.1
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 +11 -6
- package/lib/core/SnapshotReadOperations.d.ts +119 -0
- package/lib/core/SnapshotReadOperations.js +3 -0
- package/lib/core/SnapshotReadOperations.js.map +1 -0
- package/lib/core/SnapshotReadOperations.mjs +2 -0
- package/lib/core/SnapshotReadOperations.mjs.map +1 -0
- package/lib/core/TtscMetroOptions.d.ts +119 -0
- package/lib/core/TtscMetroOptions.js +3 -0
- package/lib/core/TtscMetroOptions.js.map +1 -0
- package/lib/core/TtscMetroOptions.mjs +2 -0
- package/lib/core/TtscMetroOptions.mjs.map +1 -0
- package/lib/core/TtscMetroProjectView.d.ts +64 -0
- package/lib/core/TtscMetroProjectView.js +3 -0
- package/lib/core/TtscMetroProjectView.js.map +1 -0
- package/lib/core/TtscMetroProjectView.mjs +2 -0
- package/lib/core/TtscMetroProjectView.mjs.map +1 -0
- package/lib/core/UpstreamTransformer.d.ts +127 -0
- package/lib/core/UpstreamTransformer.js +3 -0
- package/lib/core/UpstreamTransformer.js.map +1 -0
- package/lib/core/UpstreamTransformer.mjs +2 -0
- package/lib/core/UpstreamTransformer.mjs.map +1 -0
- package/lib/core/fingerprint.d.ts +442 -97
- package/lib/core/fingerprint.js +838 -83
- package/lib/core/fingerprint.js.map +1 -1
- package/lib/core/fingerprint.mjs +839 -84
- package/lib/core/fingerprint.mjs.map +1 -1
- package/lib/core/options.d.ts +81 -58
- package/lib/core/options.js +81 -4
- package/lib/core/options.js.map +1 -1
- package/lib/core/options.mjs +81 -4
- package/lib/core/options.mjs.map +1 -1
- package/lib/core/remapAstLocations.d.ts +76 -0
- package/lib/core/remapAstLocations.js +205 -0
- package/lib/core/remapAstLocations.js.map +1 -0
- package/lib/core/remapAstLocations.mjs +203 -0
- package/lib/core/remapAstLocations.mjs.map +1 -0
- package/lib/core/upstream.d.ts +97 -27
- package/lib/core/upstream.js +109 -8
- package/lib/core/upstream.js.map +1 -1
- package/lib/core/upstream.mjs +109 -9
- package/lib/core/upstream.mjs.map +1 -1
- package/lib/index.d.ts +50 -2
- package/lib/index.js +88 -24
- package/lib/index.js.map +1 -1
- package/lib/index.mjs +89 -25
- package/lib/index.mjs.map +1 -1
- package/lib/transformer.d.ts +183 -6
- package/lib/transformer.js +209 -14
- package/lib/transformer.js.map +1 -1
- package/lib/transformer.mjs +211 -16
- package/lib/transformer.mjs.map +1 -1
- package/package.json +7 -18
- package/src/core/SnapshotReadOperations.ts +121 -0
- package/src/core/TtscMetroOptions.ts +127 -0
- package/src/core/TtscMetroProjectView.ts +74 -0
- package/src/core/UpstreamTransformer.ts +126 -0
- package/src/core/fingerprint.ts +987 -109
- package/src/core/options.ts +85 -62
- package/src/core/remapAstLocations.ts +219 -0
- package/src/core/upstream.ts +110 -26
- package/src/index.ts +99 -28
- package/src/transformer.ts +214 -17
package/lib/core/fingerprint.js
CHANGED
|
@@ -25,16 +25,29 @@ var path = require('node:path');
|
|
|
25
25
|
* (samchon/ttsc#718) reports each transform's derived inputs. Workers retain
|
|
26
26
|
* them under `node_modules/.cache/ttsc-metro`, compare their generation state
|
|
27
27
|
* with the exact main-process key baseline, and batch one durable write per
|
|
28
|
-
* delivered module.
|
|
28
|
+
* delivered module. Directory-listing predicates retain their paths
|
|
29
|
+
* separately so the next static key captures the exact compiler-accessible
|
|
30
|
+
* files and directories, rather than reconstructing an earlier baseline after
|
|
31
|
+
* workers have run.
|
|
32
|
+
*
|
|
33
|
+
* Native contributor codecs retain separate per-path kind requests; their raw
|
|
34
|
+
* byte, entry and directory facts are captured anew for the next key. Schema
|
|
35
|
+
* four snapshots cannot establish those requests and restart with a fresh epoch.
|
|
29
36
|
*
|
|
30
37
|
* Snapshot layout: one main file carrying a random epoch id plus per-worker
|
|
31
38
|
* files with unique names, so concurrent workers never race a shared write.
|
|
32
39
|
* `withTtsc` compacts worker files into the main file before its workers exist,
|
|
33
40
|
* under a process-shared lock for builds using the same project cache. Readers
|
|
34
41
|
* take the union of every file, reading the worker files strictly before the
|
|
35
|
-
* main file
|
|
36
|
-
*
|
|
37
|
-
*
|
|
42
|
+
* main file. The compactor renames each worker file to a claimed name first,
|
|
43
|
+
* renames the merged main into place next, and deletes the claimed files last.
|
|
44
|
+
* A name that vanishes between a reader's listing and its read may therefore be
|
|
45
|
+
* a claim whose merge is not published yet, so the reader lists again until a
|
|
46
|
+
* pass reads every name it saw, and degrades to a nonce when the directory does
|
|
47
|
+
* not settle. A claimed file deleted after the merge is already inside the main
|
|
48
|
+
* the reader loads afterwards. A worker file the compactor could not delete
|
|
49
|
+
* stays, and the main names it as compacted, so no reader or later compaction
|
|
50
|
+
* merges it twice.
|
|
38
51
|
*
|
|
39
52
|
* Sound degradations, by design:
|
|
40
53
|
*
|
|
@@ -55,7 +68,7 @@ var path = require('node:path');
|
|
|
55
68
|
* output stored under the earlier A key.
|
|
56
69
|
*/
|
|
57
70
|
/** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
|
|
58
|
-
const SNAPSHOT_VERSION =
|
|
71
|
+
const SNAPSHOT_VERSION = 5;
|
|
59
72
|
/** Snapshot directory segments under the fingerprint base directory. */
|
|
60
73
|
const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
|
|
61
74
|
/** Recovery-document prefix in the parent cache directory. */
|
|
@@ -66,6 +79,18 @@ const MAIN_SNAPSHOT = "graph-inputs.json";
|
|
|
66
79
|
const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
|
|
67
80
|
/** Prefix used after a compactor atomically claims an immutable worker file. */
|
|
68
81
|
const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
|
|
82
|
+
/**
|
|
83
|
+
* Listing passes a reader repeats while a listed snapshot name keeps vanishing
|
|
84
|
+
* before it can be read. Each repeat needs another compactor to rename or
|
|
85
|
+
* remove a file, so exhausting the bound means the directory is not settling
|
|
86
|
+
* and the state cannot be proven.
|
|
87
|
+
*/
|
|
88
|
+
const SNAPSHOT_LISTING_ATTEMPTS = 8;
|
|
89
|
+
const HOST_SNAPSHOT_READ_OPERATIONS = {
|
|
90
|
+
existsSync: (file) => fs.existsSync(file),
|
|
91
|
+
readFileSync: (file, encoding) => fs.readFileSync(file, encoding),
|
|
92
|
+
readdirSync: (directory) => fs.readdirSync(directory),
|
|
93
|
+
};
|
|
69
94
|
/** Directory lock serializing the one mutable main-snapshot rewrite. */
|
|
70
95
|
const SNAPSHOT_COMPACTION_LOCK = "snapshot-compaction.lock";
|
|
71
96
|
/** Complete owner record stored inside an atomically published lock. */
|
|
@@ -82,6 +107,40 @@ const unhealthySnapshots = new Set();
|
|
|
82
107
|
* from Metro's cache-key options, the transformer from each file's transform
|
|
83
108
|
* options — all the same value in a real Metro run), else the working directory
|
|
84
109
|
* Metro was launched from.
|
|
110
|
+
*
|
|
111
|
+
* @evidence contracts/common.md#principled-implementation
|
|
112
|
+
* Node path.resolve provides one agreed base for config preparation, static
|
|
113
|
+
* keying and worker recording. A nonempty projectRoot wins; absent or empty
|
|
114
|
+
* input uses cwd.
|
|
115
|
+
*
|
|
116
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
117
|
+
* One helper defines the shared base fallback for preparation, keying and
|
|
118
|
+
* worker recording instead of giving each boundary its own anchor policy.
|
|
119
|
+
*
|
|
120
|
+
* @evidenceExclude contracts/performance.md#efficient-algorithms
|
|
121
|
+
* This operation delegates one path resolution and chooses a fallback; it
|
|
122
|
+
* owns no collection-processing strategy.
|
|
123
|
+
*
|
|
124
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
125
|
+
* The base depends on caller input and current cwd; this pure mapping
|
|
126
|
+
* coordinates no shared computation.
|
|
127
|
+
*
|
|
128
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
129
|
+
* It returns a directory string and owns no retained state or handle.
|
|
130
|
+
*
|
|
131
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
132
|
+
* It computes a directory value without changing the filesystem, foreign
|
|
133
|
+
* APIs or consumer-specific behavior.
|
|
134
|
+
*
|
|
135
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
136
|
+
* Node path.resolve and process.cwd handle native path roots and separators
|
|
137
|
+
* on supported OSes. No URL conversion or process spawning occurs.
|
|
138
|
+
*
|
|
139
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
140
|
+
* The native JSDoc explains shared base ownership and the absent/empty
|
|
141
|
+
* projectRoot fallback. Checked against the documentation skill: separate
|
|
142
|
+
* paragraphs state the contract and why its nonobvious boundary matters;
|
|
143
|
+
* field comments retain their own useful facts.
|
|
85
144
|
*/
|
|
86
145
|
function resolveFingerprintBase(projectRoot) {
|
|
87
146
|
return path.resolve(typeof projectRoot === "string" && projectRoot.length !== 0
|
|
@@ -89,13 +148,53 @@ function resolveFingerprintBase(projectRoot) {
|
|
|
89
148
|
: process.cwd());
|
|
90
149
|
}
|
|
91
150
|
/**
|
|
92
|
-
* The directories whose walk universes the fingerprint hashes
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
151
|
+
* The directories whose walk universes the fingerprint hashes. Implicit
|
|
152
|
+
* selection searches from the base upward, so a config at the base uses that
|
|
153
|
+
* walk and a config above it replaces the base walk with its own directory,
|
|
154
|
+
* which contains the base. An explicit project inside the base uses the base
|
|
155
|
+
* walk; one above the base replaces it; one elsewhere adds its directory. The
|
|
156
|
+
* separate per-file project view can select a nested config below the base.
|
|
157
|
+
* Matching the transform core's validation universe keeps the invariant simple:
|
|
158
|
+
* everything it treats as an input is fingerprinted by the walk, the recorded
|
|
159
|
+
* snapshot, or both.
|
|
160
|
+
*
|
|
161
|
+
* @evidence contracts/common.md#principled-implementation
|
|
162
|
+
* The shared project resolver owns tsconfig selection; lexical path
|
|
163
|
+
* containment is separate from program membership.
|
|
164
|
+
* Blank explicit options mean implicit discovery. An in-root config avoids a
|
|
165
|
+
* duplicate whole-tree walk; a config above the base walks its own directory,
|
|
166
|
+
* which contains the base, instead of hashing the base subtree twice; an
|
|
167
|
+
* unrelated out-of-root config adds its directory because it can supply
|
|
168
|
+
* transform inputs.
|
|
169
|
+
*
|
|
170
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
171
|
+
* Project selection delegates to the shared resolver and containment to
|
|
172
|
+
* projectViewRoots, separating config ownership from walk-root choice.
|
|
173
|
+
*
|
|
174
|
+
* @evidenceExclude contracts/performance.md#efficient-algorithms
|
|
175
|
+
* It delegates shared project discovery and computes at most two roots;
|
|
176
|
+
* traversal cost belongs to that resolver and the fingerprint operation.
|
|
177
|
+
*
|
|
178
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
179
|
+
* This mapping retains no discovery result between calls and coordinates
|
|
180
|
+
* no completed or in-flight computation.
|
|
181
|
+
*
|
|
182
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
183
|
+
* Roots transfer to the caller; no retained state or handle is acquired.
|
|
184
|
+
*
|
|
185
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
186
|
+
* No fixture-specific roots or patched filesystem APIs are used.
|
|
187
|
+
*
|
|
188
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
189
|
+
* Node resolve and dirname handle Windows drives and POSIX roots. Lexical
|
|
190
|
+
* containment compares complete path components with only volume-root case
|
|
191
|
+
* normalization; it does not merge distinct native names by OS assumption.
|
|
192
|
+
*
|
|
193
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
194
|
+
* The native JSDoc explains walk roots, implicit selection and why outside
|
|
195
|
+
* configs extend coverage. Checked against the documentation skill:
|
|
196
|
+
* separate paragraphs state the contract and why its nonobvious boundary
|
|
197
|
+
* matters; field comments retain their own useful facts.
|
|
99
198
|
*/
|
|
100
199
|
function fingerprintRoots(base, explicitProject) {
|
|
101
200
|
const explicit = normalizedExplicitProject(explicitProject);
|
|
@@ -112,22 +211,74 @@ function projectViewRoots(base, tsconfig, explicitProject) {
|
|
|
112
211
|
// the whole project twice on every cache key (samchon/ttsc#1307).
|
|
113
212
|
const resolvedBase = path.resolve(base);
|
|
114
213
|
const directory = path.dirname(path.resolve(tsconfig));
|
|
115
|
-
const
|
|
116
|
-
const inside = relative === "" ||
|
|
117
|
-
(relative !== ".." &&
|
|
118
|
-
!relative.startsWith(`..${path.sep}`) &&
|
|
119
|
-
!path.isAbsolute(relative));
|
|
214
|
+
const inside = pathIsWithin(directory, resolvedBase);
|
|
120
215
|
if (explicitProject === undefined && inside && directory !== resolvedBase) {
|
|
121
216
|
return [directory];
|
|
122
217
|
}
|
|
123
|
-
|
|
218
|
+
if (inside)
|
|
219
|
+
return [resolvedBase];
|
|
220
|
+
// A config above the base owns a walk that already contains the base
|
|
221
|
+
// subtree under the same membership policy, so walking the base as well would
|
|
222
|
+
// hash every file below it twice on each key. A config beside or elsewhere
|
|
223
|
+
// does not contain the base, so both roots stay.
|
|
224
|
+
return pathIsWithin(resolvedBase, directory)
|
|
225
|
+
? [directory]
|
|
226
|
+
: [resolvedBase, directory];
|
|
124
227
|
}
|
|
125
228
|
/**
|
|
126
229
|
* Resolve one transform's project view, once, for every watch input it reports.
|
|
127
230
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
231
|
+
* Discovery, referenced-project selection and membership policy are resolved
|
|
232
|
+
* once per delivered module and shared by its recorder batch. The policy is
|
|
233
|
+
* read from source rather than trusting a cached mtime/size observation, which
|
|
234
|
+
* could miss same-stamp edits.
|
|
235
|
+
*
|
|
236
|
+
* @evidence contracts/common.md#principled-implementation
|
|
237
|
+
* Shared project discovery, reference selection and membership-policy APIs
|
|
238
|
+
* select the same config as the transform core, retaining positive and
|
|
239
|
+
* negative discovery predicates. Consulted routing configs retain current
|
|
240
|
+
* content, since their references or admission rules can change the owner.
|
|
241
|
+
* Compiler-option overlays are merged through that owner.
|
|
242
|
+
*
|
|
243
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
244
|
+
* Selection records the predicates that chose the project, then one
|
|
245
|
+
* createProjectView helper assembles its policy and roots for every input
|
|
246
|
+
* in the module's recorder batch.
|
|
247
|
+
*
|
|
248
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
249
|
+
* Discovery follows the ancestor candidates and reference graph; the
|
|
250
|
+
* resulting policy and roots are assembled once for the delivered module.
|
|
251
|
+
* Candidate predicates and consulted config content are captured once for
|
|
252
|
+
* this view, with a lexical-key map merging their overlapping paths in
|
|
253
|
+
* linear map operations. Capturing content performs the shared baseline's
|
|
254
|
+
* two observations and scales with the consulted config bytes.
|
|
255
|
+
*
|
|
256
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
257
|
+
* The returned view is reused by the module's recorder batch; its selected
|
|
258
|
+
* tsconfig is passed to the transform options. Config sources are reread for
|
|
259
|
+
* a later module because matching mtime and size cannot establish continued
|
|
260
|
+
* equivalence.
|
|
261
|
+
*
|
|
262
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
263
|
+
* Returned project data transfers to the caller. This operation retains no
|
|
264
|
+
* process-wide state or handle after discovery completes.
|
|
265
|
+
*
|
|
266
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
267
|
+
* The view is resolved once per module and shared with its batch, avoiding
|
|
268
|
+
* independent recorder discovery without hardcoded project or test names.
|
|
269
|
+
*
|
|
270
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
271
|
+
* Node absolute paths and host discovery predicates retain native
|
|
272
|
+
* filesystem identity. The injected discovery filesystem is an explicit
|
|
273
|
+
* observation boundary, and path-relative helpers respect native roots; no
|
|
274
|
+
* shell command or global filesystem patch is used.
|
|
275
|
+
*
|
|
276
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
277
|
+
* The native JSDoc explains selection inputs, returned coupled view,
|
|
278
|
+
* discovery evidence and per-module reuse. Checked against the
|
|
279
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
280
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
281
|
+
* facts.
|
|
131
282
|
*/
|
|
132
283
|
function resolveProjectView(props) {
|
|
133
284
|
const base = resolveFingerprintBase(props.projectRoot);
|
|
@@ -138,15 +289,44 @@ function resolveProjectView(props) {
|
|
|
138
289
|
const discovery = explicitProject === undefined
|
|
139
290
|
? api.discoverNearestProjectTsconfig(start, props.projectDiscoveryFilesystem)
|
|
140
291
|
: undefined;
|
|
141
|
-
const
|
|
292
|
+
const nearest = discovery === undefined
|
|
142
293
|
? resolveProjectTsconfig(start, explicitProject)
|
|
143
294
|
: (discovery.file ?? path.resolve(process.cwd(), "tsconfig.json"));
|
|
295
|
+
const selection = explicitProject === undefined && props.filename !== undefined
|
|
296
|
+
? api.selectReferencedProject(path.resolve(props.filename), nearest)
|
|
297
|
+
: { consulted: [], tsconfig: nearest };
|
|
298
|
+
const tsconfig = selection.tsconfig;
|
|
144
299
|
const discoveryInputs = discovery === undefined
|
|
145
300
|
? [captureProjectDiscoveryInput(tsconfig)]
|
|
146
301
|
: discovery.candidates.map((candidate) => captureProjectDiscoveryInput(candidate.file, candidate.fileExists));
|
|
147
302
|
if (!discoveryInputs.some((input) => samePath(input.file, tsconfig))) {
|
|
148
303
|
discoveryInputs.push(captureProjectDiscoveryInput(tsconfig));
|
|
149
304
|
}
|
|
305
|
+
const discoveryIndexes = new Map(discoveryInputs.map((input, index) => [snapshotPathKey(input.file), index]));
|
|
306
|
+
for (const config of selection.consulted) {
|
|
307
|
+
const baseline = api.captureWatchInputBaseline(config);
|
|
308
|
+
const input = baseline === undefined
|
|
309
|
+
? { file: config }
|
|
310
|
+
: {
|
|
311
|
+
evidence: {
|
|
312
|
+
identity: baseline.identity,
|
|
313
|
+
missing: !baseline.fileExists,
|
|
314
|
+
state: { codec: "host", hash: baseline.hostHash },
|
|
315
|
+
...(!baseline.fileExists
|
|
316
|
+
? { unavailable: "missing" }
|
|
317
|
+
: {}),
|
|
318
|
+
},
|
|
319
|
+
file: config,
|
|
320
|
+
};
|
|
321
|
+
const key = snapshotPathKey(config);
|
|
322
|
+
const index = discoveryIndexes.get(key);
|
|
323
|
+
if (index === undefined) {
|
|
324
|
+
discoveryIndexes.set(key, discoveryInputs.length);
|
|
325
|
+
discoveryInputs.push(input);
|
|
326
|
+
}
|
|
327
|
+
else
|
|
328
|
+
discoveryInputs[index] = input;
|
|
329
|
+
}
|
|
150
330
|
return createProjectView({
|
|
151
331
|
base,
|
|
152
332
|
compilerOptions: props.compilerOptions,
|
|
@@ -250,6 +430,23 @@ function fingerprintProjectViews(props) {
|
|
|
250
430
|
tsconfig: resolved,
|
|
251
431
|
}), props.compilerOptions));
|
|
252
432
|
}
|
|
433
|
+
// A solution config routes its files to the projects it references, and the
|
|
434
|
+
// worker compiles those (samchon/ttsc#1397), so their configs and inputs
|
|
435
|
+
// key the cache as well. The solution's own snapshot covers its list.
|
|
436
|
+
for (const project of [...projects]) {
|
|
437
|
+
for (const referenced of api.searchedReferencedProjects(project.tsconfig)) {
|
|
438
|
+
if (!fs.existsSync(referenced) ||
|
|
439
|
+
projects.some((known) => samePath(known.tsconfig, referenced))) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
projects.push(stableFingerprintProjectView(createProjectView({
|
|
443
|
+
base: primary.base,
|
|
444
|
+
compilerOptions: props.compilerOptions,
|
|
445
|
+
explicitProject: undefined,
|
|
446
|
+
tsconfig: referenced,
|
|
447
|
+
}), props.compilerOptions));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
253
450
|
const secondMap = api.findProjectTsconfigs(primary.base, props.projectDiscoveryFilesystem);
|
|
254
451
|
const selectedAfter = resolveProjectView(props);
|
|
255
452
|
if (!secondMap.complete ||
|
|
@@ -318,22 +515,78 @@ function sameProjectMap(left, right) {
|
|
|
318
515
|
return (left.length === right.length &&
|
|
319
516
|
left.every((entry, index) => samePath(entry, right[index])));
|
|
320
517
|
}
|
|
321
|
-
/**
|
|
518
|
+
/** Equality of resolved lexical spellings, preserving distinct native names. */
|
|
322
519
|
function samePath(left, right) {
|
|
323
|
-
return
|
|
520
|
+
return snapshotPathKey(left) === snapshotPathKey(right);
|
|
324
521
|
}
|
|
325
522
|
/** Whether one resolved path lies at or below another. */
|
|
326
523
|
function pathIsWithin(child, parent) {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
!path.isAbsolute(relative)));
|
|
524
|
+
const childKey = snapshotPathKey(child);
|
|
525
|
+
const parentKey = snapshotPathKey(parent);
|
|
526
|
+
return (childKey === parentKey ||
|
|
527
|
+
childKey.startsWith(parentKey.endsWith(path.sep) ? parentKey : `${parentKey}${path.sep}`));
|
|
332
528
|
}
|
|
333
529
|
/**
|
|
334
530
|
* Compute the fingerprint `getCacheKey` folds into Metro's static transformer
|
|
335
531
|
* key. Never throws: any failure degrades to a nonce, which soundly disables
|
|
336
532
|
* cross-run cache reuse for this run instead of serving stale output.
|
|
533
|
+
*
|
|
534
|
+
* A successful call makes two complete observations of the routed projects,
|
|
535
|
+
* config sources and recorded inputs. Work scales with their files and bytes;
|
|
536
|
+
* it is paid at Metro's static-key boundary, not once per delivered module. The
|
|
537
|
+
* run baseline retains exact hashes, identities and discovery predicates so a
|
|
538
|
+
* worker can prove that its generation matches the state keyed here.
|
|
539
|
+
*
|
|
540
|
+
* @evidence contracts/common.md#principled-implementation
|
|
541
|
+
* The shared project walker/policy and input-state APIs supply real file,
|
|
542
|
+
* config, identity and recorded-input observations. Two complete
|
|
543
|
+
* observations must agree before Node sha256 publishes the key and immutable
|
|
544
|
+
* run baseline. Missing, volatile, incomplete or changing state yields
|
|
545
|
+
* randomBytes nonce, a required withdrawal of reuse rather than a successful
|
|
546
|
+
* stale fallback. Injected discovery changes observation only, not
|
|
547
|
+
* production results.
|
|
548
|
+
*
|
|
549
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
550
|
+
* The entry point compares two observations and publishes their baseline;
|
|
551
|
+
* observeProjectFingerprint owns collecting the complete input universe.
|
|
552
|
+
* Nonreusable state has one nonce exit rather than partial-success keys.
|
|
553
|
+
*
|
|
554
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
555
|
+
* Two observations scan project files and recorded inputs, with time driven
|
|
556
|
+
* by their bytes and config graph size. Overlapping lexical project walks
|
|
557
|
+
* are excluded by routed roots; maps deduplicate config sources. Exact
|
|
558
|
+
* content observations are necessary because timestamps do not prove state.
|
|
559
|
+
*
|
|
560
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
561
|
+
* The static key shares transforms only for equal options, config contents,
|
|
562
|
+
* file hashes, identities, recorded generation state and requested exact
|
|
563
|
+
* compiler-accessible entry listings. Volatile, tainted
|
|
564
|
+
* or unstable observations take a fresh nonce; a run baseline must be
|
|
565
|
+
* immutable before workers can claim its coverage.
|
|
566
|
+
*
|
|
567
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
568
|
+
* Observation maps are local to key computation and are released on return.
|
|
569
|
+
* The persisted run baseline is owned by snapshot preparation, which sweeps
|
|
570
|
+
* old baselines. A long-running worker can retain its loaded baseline until
|
|
571
|
+
* worker termination; it owns no open handle here.
|
|
572
|
+
*
|
|
573
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
574
|
+
* Decision values come from the documented inputs and product protocol
|
|
575
|
+
* rather than expected test answers. No compensating path is introduced to
|
|
576
|
+
* make a known example pass.
|
|
577
|
+
*
|
|
578
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
579
|
+
* Node paths/fs preserve native roots and physical identities, including
|
|
580
|
+
* symlink-aware discovery. Sorted key representation is deterministic; file
|
|
581
|
+
* bytes and host predicates remain inputs. Filesystem failure withdraws
|
|
582
|
+
* reuse consistently instead of becoming an OS-specific successful key.
|
|
583
|
+
*
|
|
584
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
585
|
+
* The native JSDoc explains hex digest versus nonce, repeated observation,
|
|
586
|
+
* private baseline publication and failure policy. Checked against the
|
|
587
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
588
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
589
|
+
* facts.
|
|
337
590
|
*/
|
|
338
591
|
function computeProjectFingerprint(props) {
|
|
339
592
|
try {
|
|
@@ -407,12 +660,24 @@ function observeProjectFingerprint(props) {
|
|
|
407
660
|
if (snapshot === undefined || snapshot.volatile || snapshot.tainted) {
|
|
408
661
|
throw new Error("Metro's recorded transform snapshot is not reusable.");
|
|
409
662
|
}
|
|
663
|
+
// A plugin's Go source is a recorded input like any other, but no one
|
|
664
|
+
// path's state stands for the files below it, so the key carries its state
|
|
665
|
+
// (samchon/ttsc#1487).
|
|
666
|
+
const trees = new Set(snapshot.trees);
|
|
667
|
+
const accessibleEntries = new Set(snapshot.accessibleEntries);
|
|
410
668
|
const recorded = {};
|
|
411
669
|
for (const file of snapshot.files) {
|
|
412
|
-
const baseline = addBaselineInput(inputs, file);
|
|
670
|
+
const baseline = addBaselineInput(inputs, file, undefined, trees.has(file), accessibleEntries.has(file), snapshot.nativePredicates[file]);
|
|
413
671
|
recorded[snapshotPathKey(file)] = {
|
|
414
672
|
hash: baseline.hostHash,
|
|
415
673
|
identity: baseline.identity,
|
|
674
|
+
...(baseline.tree === undefined ? {} : { tree: baseline.tree }),
|
|
675
|
+
...(baseline.accessibleEntries === undefined
|
|
676
|
+
? {}
|
|
677
|
+
: { accessibleEntries: baseline.accessibleEntries }),
|
|
678
|
+
...(baseline.nativePredicates === undefined
|
|
679
|
+
? {}
|
|
680
|
+
: { nativePredicates: baseline.nativePredicates }),
|
|
416
681
|
};
|
|
417
682
|
}
|
|
418
683
|
return {
|
|
@@ -425,28 +690,61 @@ function observeProjectFingerprint(props) {
|
|
|
425
690
|
staticInputs: [...staticInputs].sort(),
|
|
426
691
|
};
|
|
427
692
|
}
|
|
428
|
-
/**
|
|
429
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Add one lexical path's stable broad state to a key baseline, with its plugin
|
|
695
|
+
* source state when it was recorded as a plugin source directory (`tree`).
|
|
696
|
+
* Recorded listing predicates request exact immediate accessible entries; these
|
|
697
|
+
* optional payloads merge only after their shared broad state agrees. Native
|
|
698
|
+
* kind requests capture the raw producer codec independently of those listings.
|
|
699
|
+
* The returned state includes every payload already observed at the same key,
|
|
700
|
+
* so a later alias cannot erase an earlier payload from the fingerprint.
|
|
701
|
+
*/
|
|
702
|
+
function addBaselineInput(inputs, file, staticInputs, tree = false, accessibleEntries = false, nativePredicates) {
|
|
430
703
|
const key = snapshotPathKey(file);
|
|
431
|
-
const observed = api.captureWatchInputBaseline(file
|
|
704
|
+
const observed = api.captureWatchInputBaseline(file, undefined, {
|
|
705
|
+
tree,
|
|
706
|
+
accessibleEntries,
|
|
707
|
+
nativePredicates,
|
|
708
|
+
});
|
|
432
709
|
if (observed === undefined) {
|
|
433
710
|
throw new Error("Unable to read a stable Metro input baseline.");
|
|
434
711
|
}
|
|
435
712
|
const existing = inputs[key];
|
|
713
|
+
let baseline = observed;
|
|
436
714
|
if (existing !== undefined) {
|
|
437
|
-
|
|
715
|
+
// One path observed as a plugin source and as a plain input compares on
|
|
716
|
+
// what both observations carry.
|
|
717
|
+
const { tree: existingTree, accessibleEntries: existingEntries, nativePredicates: existingNative, ...existingState } = existing;
|
|
718
|
+
const { tree: observedTree, accessibleEntries: observedEntries, nativePredicates: observedNative, ...observedState } = observed;
|
|
719
|
+
if (Object.entries(existingNative ?? {}).some(([kind, state]) => observedNative?.[kind] !== undefined &&
|
|
720
|
+
stableStringify(state) !==
|
|
721
|
+
stableStringify(observedNative[kind])) ||
|
|
722
|
+
existing.identity !== observed.identity ||
|
|
438
723
|
existing.fileExists !== observed.fileExists ||
|
|
439
724
|
("hostHash" in existing &&
|
|
440
|
-
stableStringify(
|
|
725
|
+
stableStringify(existingState) !== stableStringify(observedState)) ||
|
|
726
|
+
(existingTree !== undefined &&
|
|
727
|
+
observedTree !== undefined &&
|
|
728
|
+
existingTree !== observedTree) ||
|
|
729
|
+
(existingEntries !== undefined &&
|
|
730
|
+
observedEntries !== undefined &&
|
|
731
|
+
(stableStringify(existingEntries.files) !==
|
|
732
|
+
stableStringify(observedEntries.files) ||
|
|
733
|
+
stableStringify(existingEntries.directories) !==
|
|
734
|
+
stableStringify(observedEntries.directories)))) {
|
|
441
735
|
throw new Error("A Metro input changed between baseline observations.");
|
|
442
736
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
737
|
+
baseline = {
|
|
738
|
+
...existing,
|
|
739
|
+
...observed,
|
|
740
|
+
...(existingNative === undefined && observedNative === undefined
|
|
741
|
+
? {}
|
|
742
|
+
: { nativePredicates: { ...existingNative, ...observedNative } }),
|
|
743
|
+
};
|
|
447
744
|
}
|
|
745
|
+
inputs[key] = baseline;
|
|
448
746
|
staticInputs?.add(key);
|
|
449
|
-
return
|
|
747
|
+
return baseline;
|
|
450
748
|
}
|
|
451
749
|
/** Add the stable file predicate used by the project-map traversal. */
|
|
452
750
|
function addDiscoveryBaselineInput(inputs, file, staticInputs) {
|
|
@@ -487,14 +785,71 @@ function nonce() {
|
|
|
487
785
|
* token crosses bundle and process boundaries, so `getCacheKey` degrades to a
|
|
488
786
|
* nonce even when neither snapshot location can persist the failure and an old
|
|
489
787
|
* main later reappears.
|
|
788
|
+
*
|
|
789
|
+
* Compaction reads the accumulated worker and recovery documents and unions
|
|
790
|
+
* their recorded paths. Workers have unique names; completed observations are
|
|
791
|
+
* published before removal. Locked claimed files remain until a later retry.
|
|
792
|
+
* Old temporary files and run baselines are swept, but recorded input paths
|
|
793
|
+
* accumulate in the main snapshot and dead-owner election directories remain to
|
|
794
|
+
* prevent delayed contenders from moving a newer lock. Those populations are
|
|
795
|
+
* not bounded by a fixed retention budget in this implementation.
|
|
796
|
+
*
|
|
797
|
+
* @evidence contracts/common.md#principled-implementation
|
|
798
|
+
* Node filesystem APIs atomically publish complete snapshot documents and a
|
|
799
|
+
* token-owned compaction lock. Worker claims are merged before deletion;
|
|
800
|
+
* retained claims are identified in the main document to prevent replay.
|
|
801
|
+
* Definite dead-PID recovery preserves an election record; contention or
|
|
802
|
+
* failed persistence returns a private nonreusable token.
|
|
803
|
+
*
|
|
804
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
805
|
+
* Preparation owns one lock-protected main rewrite. Helpers separate worker
|
|
806
|
+
* claims, document parsing, atomic publication and recovery storage, so
|
|
807
|
+
* concurrent readers can use the same persisted protocol.
|
|
808
|
+
*
|
|
809
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
810
|
+
* Sets union recorded paths in time proportional to document paths, then
|
|
811
|
+
* sorting gives deterministic output. Each claimed worker is merged once;
|
|
812
|
+
* readdir and document parsing remain necessary to find pending publishers.
|
|
813
|
+
*
|
|
814
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
815
|
+
* The main document records claimed worker names before removal, preventing
|
|
816
|
+
* locked files from replaying completed compaction. The epoch survives only
|
|
817
|
+
* trusted, untainted observations; corrupt or lost evidence rotates it.
|
|
818
|
+
*
|
|
819
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
820
|
+
* Preparation owns its compaction lock and retires it in finally. Claimed
|
|
821
|
+
* workers and recovery files are removed after publication; failed removals
|
|
822
|
+
* stay represented for retry. Temporary files and baselines have age sweeps,
|
|
823
|
+
* but cumulative input paths and stale-owner election records have no fixed
|
|
824
|
+
* bound. Removing these without a generation protocol could erase evidence.
|
|
825
|
+
*
|
|
826
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
827
|
+
* Retries serve locked-file/recovery contracts rather than masking an
|
|
828
|
+
* uncorrected successful-state assumption.
|
|
829
|
+
*
|
|
830
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
831
|
+
* Node join, mkdir, rename and rm implement native cache paths.
|
|
832
|
+
* Windows-locked files remain represented for a later removal attempt; only
|
|
833
|
+
* a definite process-query absence permits dead-owner recovery. No shell
|
|
834
|
+
* process checks or patched fs methods are used.
|
|
835
|
+
*
|
|
836
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
837
|
+
* The native JSDoc explains new-run preparation, contention, epoch
|
|
838
|
+
* rotation, recovery storage and nonreusable tokens. Checked against the
|
|
839
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
840
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
841
|
+
* facts.
|
|
490
842
|
*/
|
|
491
843
|
function prepareSnapshot(projectRoot) {
|
|
492
844
|
const base = resolveFingerprintBase(projectRoot);
|
|
493
845
|
const runId = node_crypto.randomBytes(16).toString("hex");
|
|
494
846
|
let reusable = true;
|
|
495
847
|
let pending = {
|
|
848
|
+
nativePredicates: {},
|
|
849
|
+
accessibleEntries: [],
|
|
496
850
|
files: [],
|
|
497
851
|
tainted: false,
|
|
852
|
+
trees: [],
|
|
498
853
|
version: SNAPSHOT_VERSION,
|
|
499
854
|
volatile: false,
|
|
500
855
|
};
|
|
@@ -516,9 +871,8 @@ function prepareSnapshot(projectRoot) {
|
|
|
516
871
|
return `${NON_REUSABLE_RUN_PREFIX}${runId}`;
|
|
517
872
|
}
|
|
518
873
|
// Read the worker files strictly before the main file (see the module doc
|
|
519
|
-
// comment)
|
|
520
|
-
//
|
|
521
|
-
// is already inside the main read below.
|
|
874
|
+
// comment). The lock keeps another compactor from claiming or deleting a
|
|
875
|
+
// file during these reads, and a vanished name still forces a fresh listing.
|
|
522
876
|
claimWorkerFiles(directory);
|
|
523
877
|
const recovery = readUnhealthySnapshots(base);
|
|
524
878
|
const workers = readWorkerFiles(directory);
|
|
@@ -527,7 +881,20 @@ function prepareSnapshot(projectRoot) {
|
|
|
527
881
|
}
|
|
528
882
|
const main = readMainDocument(directory);
|
|
529
883
|
const files = new Set(main?.files ?? []);
|
|
530
|
-
const
|
|
884
|
+
const trees = new Set(main?.trees ?? []);
|
|
885
|
+
const accessibleEntries = new Set(main?.accessibleEntries ?? []);
|
|
886
|
+
const nativePredicates = mergeNativePredicateRequests({}, main?.nativePredicates ?? {});
|
|
887
|
+
const observations = [
|
|
888
|
+
...recovery.entries,
|
|
889
|
+
...uncompactedWorkerEntries(workers, main),
|
|
890
|
+
];
|
|
891
|
+
// Every claimed file present is recorded before any is removed, so one
|
|
892
|
+
// whose removal fails, or a compactor that stops between the two, leaves a
|
|
893
|
+
// file the next compaction knows it already merged.
|
|
894
|
+
const compacted = workers.paths
|
|
895
|
+
.filter(isClaimedWorkerSnapshot)
|
|
896
|
+
.map((file) => path.basename(file))
|
|
897
|
+
.sort();
|
|
531
898
|
const tainted = observations.some((entry) => entry.tainted);
|
|
532
899
|
const volatile = observations.length === 0
|
|
533
900
|
? (main?.volatile ?? false)
|
|
@@ -536,19 +903,30 @@ function prepareSnapshot(projectRoot) {
|
|
|
536
903
|
// able to clear the sticky flag.
|
|
537
904
|
observations.some((entry) => entry.volatile);
|
|
538
905
|
for (const entry of observations) {
|
|
906
|
+
mergeNativePredicateRequests(nativePredicates, entry.nativePredicates);
|
|
539
907
|
for (const file of entry.files) {
|
|
540
908
|
files.add(file);
|
|
541
909
|
}
|
|
910
|
+
for (const tree of entry.trees) {
|
|
911
|
+
trees.add(tree);
|
|
912
|
+
}
|
|
913
|
+
for (const file of entry.accessibleEntries) {
|
|
914
|
+
accessibleEntries.add(file);
|
|
915
|
+
}
|
|
542
916
|
}
|
|
543
917
|
const recovering = unhealthySnapshots.has(base) ||
|
|
544
918
|
recovery.paths.length !== 0 ||
|
|
545
919
|
recovery.corruptPaths.length !== 0;
|
|
546
920
|
pending = {
|
|
921
|
+
nativePredicates,
|
|
922
|
+
accessibleEntries: [...accessibleEntries].sort(),
|
|
923
|
+
...(compacted.length !== 0 ? { compacted } : {}),
|
|
547
924
|
files: [...files].sort(),
|
|
548
925
|
id: !recovering && workers.corruptPaths.length === 0 && !tainted
|
|
549
926
|
? (main?.id ?? node_crypto.randomBytes(16).toString("hex"))
|
|
550
927
|
: node_crypto.randomBytes(16).toString("hex"),
|
|
551
928
|
tainted: false,
|
|
929
|
+
trees: [...trees].sort(),
|
|
552
930
|
version: SNAPSHOT_VERSION,
|
|
553
931
|
volatile,
|
|
554
932
|
};
|
|
@@ -565,8 +943,8 @@ function prepareSnapshot(projectRoot) {
|
|
|
565
943
|
fs.rmSync(file, { force: true });
|
|
566
944
|
}
|
|
567
945
|
catch {
|
|
568
|
-
// A locked
|
|
569
|
-
//
|
|
946
|
+
// A locked file stays for a later compaction. The main snapshot names
|
|
947
|
+
// claimed workers it merged, so readers do not replay them.
|
|
570
948
|
}
|
|
571
949
|
}
|
|
572
950
|
const remainingRecovery = readUnhealthySnapshots(base);
|
|
@@ -611,6 +989,14 @@ function prepareSnapshot(projectRoot) {
|
|
|
611
989
|
* while holding a reusable run identity. A contending process therefore
|
|
612
990
|
* degrades immediately to a nonce. The owner retires the directory atomically
|
|
613
991
|
* after every success or failure path has persisted its verdict.
|
|
992
|
+
*
|
|
993
|
+
* A lock is reaped only when its recorded process is proven gone. An owner that
|
|
994
|
+
* died and whose process id now belongs to a live process, or a lock with an
|
|
995
|
+
* unreadable owner record, is treated as held: every run that meets it takes a
|
|
996
|
+
* nonce key and no reuse is lost silently, but the cache stays unusable until
|
|
997
|
+
* that process exits or
|
|
998
|
+
* `node_modules/.cache/ttsc-metro/snapshot-compaction.lock` is deleted by hand.
|
|
999
|
+
* Readers also treat a held lock as unsettled state.
|
|
614
1000
|
*/
|
|
615
1001
|
function acquireSnapshotCompactionLock(directory) {
|
|
616
1002
|
const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
|
|
@@ -753,14 +1139,104 @@ function listExpiredKeyBaselines(directory) {
|
|
|
753
1139
|
}
|
|
754
1140
|
/**
|
|
755
1141
|
* Read the unioned snapshot state, or `undefined` when the main snapshot is
|
|
756
|
-
* missing
|
|
757
|
-
* recorded set cannot be trusted, so the caller degrades to a nonce)
|
|
1142
|
+
* missing, any snapshot file is corrupt (a torn or foreign write means the
|
|
1143
|
+
* recorded set cannot be trusted, so the caller degrades to a nonce), or a
|
|
1144
|
+
* concurrent compaction keeps renaming worker files so the listing never
|
|
1145
|
+
* settles or a compaction lock stays held.
|
|
1146
|
+
*
|
|
1147
|
+
* `operations` is the read boundary (existence, listing, file text); the
|
|
1148
|
+
* default reads the real filesystem, and a test passes its own to interleave a
|
|
1149
|
+
* compaction with a listing. A pass is retried at most
|
|
1150
|
+
* {@link SNAPSHOT_LISTING_ATTEMPTS} (8) times with a 5 ms pause between passes,
|
|
1151
|
+
* so a held lock costs a call at most about 40 ms before the state is reported
|
|
1152
|
+
* untrusted; the retry is bounded and holds no resource between passes.
|
|
1153
|
+
*
|
|
1154
|
+
* The result contains sorted absolute file and tree paths, the epoch identity,
|
|
1155
|
+
* and tainted/volatile flags. It reads persisted evidence without revalidating
|
|
1156
|
+
* current file bytes; the fingerprint operation owns that validation. Reading
|
|
1157
|
+
* costs the number and size of outstanding documents plus their unioned paths.
|
|
1158
|
+
*
|
|
1159
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1160
|
+
* Strict snapshot parsing and Set union read worker documents before the
|
|
1161
|
+
* main publication, retaining file/tree/volatile/tainted state without
|
|
1162
|
+
* replaying already compacted claims. This is the owned persisted-input
|
|
1163
|
+
* protocol, not a separate compiler dependency model.
|
|
1164
|
+
* A pass is accepted only when no compaction lock existed before or after
|
|
1165
|
+
* it and the main file is unchanged, because a listing concurrent with a
|
|
1166
|
+
* rename can return the renamed entry under neither name.
|
|
1167
|
+
* The read boundary is an optional parameter, so the interleaving can be
|
|
1168
|
+
* driven deterministically while production reads the real filesystem.
|
|
1169
|
+
*
|
|
1170
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1171
|
+
* This reader validates persisted documents and unions their paths without
|
|
1172
|
+
* recomputing file state. The fingerprint owner handles live validation;
|
|
1173
|
+
* both reader and compactor share uncompacted-worker classification.
|
|
1174
|
+
*
|
|
1175
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1176
|
+
* Reading costs document bytes plus Set insertion and final path sorting.
|
|
1177
|
+
* The compacted-name Set avoids replay and avoids a linear search per worker.
|
|
1178
|
+
*
|
|
1179
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
1180
|
+
* This read must observe new worker publications; it coordinates no cache
|
|
1181
|
+
* between calls. Cross-run reuse belongs to computeProjectFingerprint.
|
|
1182
|
+
*
|
|
1183
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
1184
|
+
* Returned snapshot data transfers to the caller. This reader retains no
|
|
1185
|
+
* state or handle after synchronous reads finish.
|
|
1186
|
+
*
|
|
1187
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1188
|
+
* Unknown, corrupt or recovery-pending state returns undefined, never an
|
|
1189
|
+
* invented complete empty set.
|
|
1190
|
+
*
|
|
1191
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
1192
|
+
* Node path and fs read native snapshot locations. Locked or unreadable
|
|
1193
|
+
* directory/documents invalidate the proof on all supported OSes; sorted
|
|
1194
|
+
* lexical paths remain values, not shell arguments.
|
|
1195
|
+
*
|
|
1196
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1197
|
+
* The native JSDoc explains unioned snapshot fields, read ordering and the
|
|
1198
|
+
* undefined result for untrusted state. Checked against the documentation
|
|
1199
|
+
* skill: separate paragraphs state the contract and why its nonobvious
|
|
1200
|
+
* boundary matters; field comments retain their own useful facts.
|
|
758
1201
|
*/
|
|
759
|
-
function readSnapshotState(base) {
|
|
1202
|
+
function readSnapshotState(base, operations = HOST_SNAPSHOT_READ_OPERATIONS) {
|
|
1203
|
+
const directory = snapshotDirectory(base);
|
|
1204
|
+
const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
|
|
1205
|
+
// A directory listing taken while another process renames entries of that
|
|
1206
|
+
// same directory is not guaranteed to return each renamed entry once: POSIX
|
|
1207
|
+
// leaves it unspecified and NTFS orders entries by name, so a claimed worker
|
|
1208
|
+
// file can appear under neither name, with its inputs not yet in the main
|
|
1209
|
+
// file. A compaction holds the lock from its first claim until after it
|
|
1210
|
+
// publishes the main file, so a pass counts only when no lock existed before
|
|
1211
|
+
// or after it and the main file read the same on both sides. A compaction
|
|
1212
|
+
// wholly inside the pass still changes the main file it publishes.
|
|
1213
|
+
for (let attempt = 0; attempt < SNAPSHOT_LISTING_ATTEMPTS; ++attempt) {
|
|
1214
|
+
if (!operations.existsSync(lock)) {
|
|
1215
|
+
const before = readMainText(directory, operations);
|
|
1216
|
+
const state = readSnapshotStateOnce(base, operations);
|
|
1217
|
+
if (!operations.existsSync(lock) &&
|
|
1218
|
+
readMainText(directory, operations) === before) {
|
|
1219
|
+
return state;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
1223
|
+
}
|
|
1224
|
+
return undefined;
|
|
1225
|
+
}
|
|
1226
|
+
/** The main snapshot's text, or `undefined` when it cannot be read. */
|
|
1227
|
+
function readMainText(directory, operations) {
|
|
1228
|
+
try {
|
|
1229
|
+
return operations.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
|
|
1230
|
+
}
|
|
1231
|
+
catch {
|
|
1232
|
+
return undefined;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function readSnapshotStateOnce(base, operations) {
|
|
760
1236
|
if (unhealthySnapshots.has(base)) {
|
|
761
1237
|
return undefined;
|
|
762
1238
|
}
|
|
763
|
-
const recovery = readUnhealthySnapshots(base);
|
|
1239
|
+
const recovery = readUnhealthySnapshots(base, operations);
|
|
764
1240
|
if (!recovery.readable ||
|
|
765
1241
|
recovery.paths.length !== 0 ||
|
|
766
1242
|
recovery.corruptPaths.length !== 0) {
|
|
@@ -768,34 +1244,124 @@ function readSnapshotState(base) {
|
|
|
768
1244
|
}
|
|
769
1245
|
const directory = snapshotDirectory(base);
|
|
770
1246
|
// Worker files strictly before the main file — see the module doc comment.
|
|
771
|
-
const workers = readWorkerFiles(directory);
|
|
1247
|
+
const workers = readWorkerFiles(directory, operations);
|
|
772
1248
|
if (!workers.readable || workers.corruptPaths.length !== 0) {
|
|
773
1249
|
return undefined;
|
|
774
1250
|
}
|
|
775
|
-
const main = readMainDocument(directory);
|
|
1251
|
+
const main = readMainDocument(directory, operations);
|
|
776
1252
|
if (main === undefined || typeof main.id !== "string") {
|
|
777
1253
|
return undefined;
|
|
778
1254
|
}
|
|
779
1255
|
const files = new Set(main.files);
|
|
1256
|
+
const trees = new Set(main.trees);
|
|
1257
|
+
const accessibleEntries = new Set(main.accessibleEntries);
|
|
1258
|
+
const nativePredicates = mergeNativePredicateRequests({}, main.nativePredicates);
|
|
780
1259
|
let volatile = main.volatile;
|
|
781
1260
|
let tainted = main.tainted;
|
|
782
|
-
for (const entry of workers
|
|
1261
|
+
for (const entry of uncompactedWorkerEntries(workers, main)) {
|
|
1262
|
+
mergeNativePredicateRequests(nativePredicates, entry.nativePredicates);
|
|
783
1263
|
for (const file of entry.files) {
|
|
784
1264
|
files.add(file);
|
|
785
1265
|
}
|
|
1266
|
+
for (const tree of entry.trees) {
|
|
1267
|
+
trees.add(tree);
|
|
1268
|
+
}
|
|
1269
|
+
for (const file of entry.accessibleEntries) {
|
|
1270
|
+
accessibleEntries.add(file);
|
|
1271
|
+
}
|
|
786
1272
|
volatile ||= entry.volatile;
|
|
787
1273
|
tainted ||= entry.tainted;
|
|
788
1274
|
}
|
|
789
|
-
return {
|
|
1275
|
+
return {
|
|
1276
|
+
nativePredicates,
|
|
1277
|
+
accessibleEntries: [...accessibleEntries].sort(),
|
|
1278
|
+
files: [...files].sort(),
|
|
1279
|
+
id: main.id,
|
|
1280
|
+
tainted,
|
|
1281
|
+
trees: [...trees].sort(),
|
|
1282
|
+
volatile,
|
|
1283
|
+
};
|
|
790
1284
|
}
|
|
791
1285
|
/**
|
|
792
1286
|
* Recorder held by each Metro worker. It persists every derived watch input and
|
|
793
1287
|
* any volatile declaration, compares compiler-generation evidence with the
|
|
794
1288
|
* matching main-process run baseline, and marks any temporal mismatch tainted.
|
|
795
1289
|
* A clean transform also writes a document so it can clear a volatile
|
|
796
|
-
* declaration from an earlier run.
|
|
797
|
-
*
|
|
798
|
-
*
|
|
1290
|
+
* declaration from an earlier run. The first delivery, a delivery that adds a
|
|
1291
|
+
* path, and every delivery once the state is tainted flush one cumulative
|
|
1292
|
+
* document; the unique name makes worker writes race-free, and `withTtsc`
|
|
1293
|
+
* compacts the files on the next run.
|
|
1294
|
+
*
|
|
1295
|
+
* `record` accepts one lexical path without generation evidence; `recordMany`
|
|
1296
|
+
* accepts a module's generation evidence; `recordVolatile` withdraws the file
|
|
1297
|
+
* proof for non-file inputs. Without a run identity every input is retained;
|
|
1298
|
+
* production passes the identity whose immutable baseline was keyed in the main
|
|
1299
|
+
* process. An unknown or mismatching baseline marks the snapshot tainted, and
|
|
1300
|
+
* since `record` carries no evidence, a run identity always reads it as a
|
|
1301
|
+
* mismatch. Listing predicates retain their paths for the next run's key
|
|
1302
|
+
* observation; native kind requests survive the same persistence path without
|
|
1303
|
+
* becoming compiler listings or gaining watch scope. The worker never adds a
|
|
1304
|
+
* new disk read to its earlier immutable baseline.
|
|
1305
|
+
*
|
|
1306
|
+
* Sets and baseline maps live for the recorder's worker lifetime and grow with
|
|
1307
|
+
* observed projects and distinct inputs. A flushing delivery serializes the
|
|
1308
|
+
* cumulative recorded set once, not once per input, but a tainted state flushes
|
|
1309
|
+
* on every delivery, so its cost grows with the recorded set for each module. A
|
|
1310
|
+
* failed write remains dirty for retry and tries recovery storage. If both
|
|
1311
|
+
* stores fail, a reusable run throws rather than publishing output backed by
|
|
1312
|
+
* lost evidence.
|
|
1313
|
+
*
|
|
1314
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1315
|
+
* One worker closure retains derived inputs and compares their
|
|
1316
|
+
* compiler-generation evidence with its immutable run baseline through
|
|
1317
|
+
* shared validation APIs. Static coverage is omitted only after a matching
|
|
1318
|
+
* proof; unknown/volatile inputs persist taint.
|
|
1319
|
+
* Listing and plugin-tree paths stay in the retained file universe even
|
|
1320
|
+
* when a static proof already covers them, so optional payloads survive
|
|
1321
|
+
* subsequent compaction and key capture.
|
|
1322
|
+
* A first or changed module batch publishes one cumulative atomic document,
|
|
1323
|
+
* with recovery storage and AggregateError when a reusable generation cannot
|
|
1324
|
+
* persist either record.
|
|
1325
|
+
*
|
|
1326
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1327
|
+
* One worker closure owns recorder state; shared recordMany owns evidence
|
|
1328
|
+
* comparison and batch publication for single and multiple inputs. flush
|
|
1329
|
+
* owns durability, so volatile observations use the same failure policy.
|
|
1330
|
+
*
|
|
1331
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1332
|
+
* Sets insert distinct inputs and a cached static-input Set checks coverage
|
|
1333
|
+
* without searching the whole baseline. A flushing module serializes the
|
|
1334
|
+
* cumulative paths once, costing their count and encoded bytes rather than
|
|
1335
|
+
* one cumulative serialization for every input. A tainted state flushes on
|
|
1336
|
+
* every module, so that cost recurs per delivery until the worker ends.
|
|
1337
|
+
*
|
|
1338
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
1339
|
+
* Each base loads its immutable run baseline once and shares it across
|
|
1340
|
+
* module batches. Only matching generation evidence authorizes omission of
|
|
1341
|
+
* static paths; missing or differing evidence records taint instead.
|
|
1342
|
+
*
|
|
1343
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
1344
|
+
* The worker owns maps of bases, baseline bytes and distinct recorded paths
|
|
1345
|
+
* until termination. Population grows with projects and inputs observed in
|
|
1346
|
+
* that worker; there is no fixed eviction budget. Persisted files transfer
|
|
1347
|
+
* to the next preparation's compactor. No native handle remains open.
|
|
1348
|
+
*
|
|
1349
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1350
|
+
* The legacy no-run handshake records all paths without claiming static
|
|
1351
|
+
* coverage.
|
|
1352
|
+
*
|
|
1353
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
1354
|
+
* Node resolve/join and atomic rename use native paths; unique PID/random
|
|
1355
|
+
* worker names separate publishers. Windows-locked claims remain readable
|
|
1356
|
+
* until later compaction, and persistence failures follow the same reuse
|
|
1357
|
+
* withdrawal contract. No foreign filesystem or process method is replaced.
|
|
1358
|
+
*
|
|
1359
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1360
|
+
* The native JSDoc explains record, batch and volatile operations,
|
|
1361
|
+
* run-baseline matching, cumulative persistence and failure ownership.
|
|
1362
|
+
* Checked against the documentation skill: separate paragraphs state the
|
|
1363
|
+
* contract and why its nonobvious boundary matters; field comments retain
|
|
1364
|
+
* their own useful facts.
|
|
799
1365
|
*/
|
|
800
1366
|
function createSnapshotRecorder(runId) {
|
|
801
1367
|
const suffix = `${process.pid.toString(36)}-${node_crypto.randomBytes(6).toString("hex")}`;
|
|
@@ -839,10 +1405,13 @@ function createSnapshotRecorder(runId) {
|
|
|
839
1405
|
let state = states.get(base);
|
|
840
1406
|
if (state === undefined) {
|
|
841
1407
|
state = {
|
|
1408
|
+
nativePredicates: {},
|
|
1409
|
+
accessibleEntries: new Set(),
|
|
842
1410
|
dirty: false,
|
|
843
1411
|
files: new Set(),
|
|
844
1412
|
observed: false,
|
|
845
1413
|
tainted: false,
|
|
1414
|
+
trees: new Set(),
|
|
846
1415
|
volatile: false,
|
|
847
1416
|
};
|
|
848
1417
|
states.set(base, state);
|
|
@@ -854,8 +1423,11 @@ function createSnapshotRecorder(runId) {
|
|
|
854
1423
|
return;
|
|
855
1424
|
}
|
|
856
1425
|
const document = {
|
|
1426
|
+
nativePredicates: state.nativePredicates,
|
|
1427
|
+
accessibleEntries: [...state.accessibleEntries].sort(),
|
|
857
1428
|
files: [...state.files].sort(),
|
|
858
1429
|
tainted: state.tainted,
|
|
1430
|
+
trees: [...state.trees].sort(),
|
|
859
1431
|
version: SNAPSHOT_VERSION,
|
|
860
1432
|
volatile: state.volatile,
|
|
861
1433
|
};
|
|
@@ -897,6 +1469,35 @@ function createSnapshotRecorder(runId) {
|
|
|
897
1469
|
state.files.add(file);
|
|
898
1470
|
state.dirty = true;
|
|
899
1471
|
}
|
|
1472
|
+
// A plugin source directory is recorded as one, so the next run's key
|
|
1473
|
+
// carries its state (samchon/ttsc#1487).
|
|
1474
|
+
if (input.evidence?.state?.codec === "tree" && !state.trees.has(file)) {
|
|
1475
|
+
state.files.add(file);
|
|
1476
|
+
state.trees.add(file);
|
|
1477
|
+
state.dirty = true;
|
|
1478
|
+
}
|
|
1479
|
+
if (input.evidence?.state?.codec === "predicates" &&
|
|
1480
|
+
input.evidence.state.observation.accessibleEntries !== undefined &&
|
|
1481
|
+
!state.accessibleEntries.has(file)) {
|
|
1482
|
+
state.files.add(file);
|
|
1483
|
+
state.accessibleEntries.add(file);
|
|
1484
|
+
state.dirty = true;
|
|
1485
|
+
}
|
|
1486
|
+
if (input.evidence?.state?.codec === "predicates") {
|
|
1487
|
+
const predicates = input.evidence.state.observation.nativePredicates;
|
|
1488
|
+
if (predicates !== undefined && predicates.length !== 0) {
|
|
1489
|
+
const previous = state.nativePredicates[file] ?? [];
|
|
1490
|
+
const kinds = [...new Set([
|
|
1491
|
+
...previous,
|
|
1492
|
+
...predicates.map((predicate) => predicate.kind),
|
|
1493
|
+
])].sort();
|
|
1494
|
+
if (kinds.length !== previous.length) {
|
|
1495
|
+
state.files.add(file);
|
|
1496
|
+
state.nativePredicates[file] = kinds;
|
|
1497
|
+
state.dirty = true;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
900
1501
|
}
|
|
901
1502
|
// A clean empty delivery must still clear a volatile verdict from the
|
|
902
1503
|
// preceding run. Persist once for the whole module, not once per input.
|
|
@@ -926,10 +1527,18 @@ function createSnapshotRecorder(runId) {
|
|
|
926
1527
|
},
|
|
927
1528
|
};
|
|
928
1529
|
}
|
|
929
|
-
/**
|
|
1530
|
+
/**
|
|
1531
|
+
* Lexical spelling used by main and worker processes. Native name case stays
|
|
1532
|
+
* exact because an OS name cannot establish directory case sensitivity. A
|
|
1533
|
+
* differently spelled alias can miss coverage and taint a run, but cannot erase
|
|
1534
|
+
* a distinct input. Only the Windows volume root is case-normalized.
|
|
1535
|
+
*/
|
|
930
1536
|
function snapshotPathKey(file) {
|
|
931
1537
|
const resolved = path.resolve(file);
|
|
932
|
-
|
|
1538
|
+
if (process.platform !== "win32")
|
|
1539
|
+
return resolved;
|
|
1540
|
+
const root = path.parse(resolved).root;
|
|
1541
|
+
return `${root.toLowerCase()}${resolved.slice(root.length)}`;
|
|
933
1542
|
}
|
|
934
1543
|
/** Persist the exact filesystem state one run's static key observed. */
|
|
935
1544
|
function writeKeyBaseline(base, runId, inputs, staticInputs) {
|
|
@@ -1005,8 +1614,8 @@ function persistUnhealthySnapshot(base, document) {
|
|
|
1005
1614
|
fs.mkdirSync(directory, { recursive: true });
|
|
1006
1615
|
writeSnapshotDocument(path.join(directory, `${UNHEALTHY_SNAPSHOT_PREFIX}${process.pid.toString(36)}-${node_crypto.randomBytes(8).toString("hex")}.json`), document);
|
|
1007
1616
|
}
|
|
1008
|
-
function readUnhealthySnapshots(base) {
|
|
1009
|
-
return readSnapshotFiles(snapshotCacheDirectory(base), UNHEALTHY_SNAPSHOT_PREFIX);
|
|
1617
|
+
function readUnhealthySnapshots(base, operations = HOST_SNAPSHOT_READ_OPERATIONS) {
|
|
1618
|
+
return readSnapshotFiles(snapshotCacheDirectory(base), UNHEALTHY_SNAPSHOT_PREFIX, operations);
|
|
1010
1619
|
}
|
|
1011
1620
|
/**
|
|
1012
1621
|
* Move each live worker document to a unique immutable name before reading it.
|
|
@@ -1046,19 +1655,47 @@ function claimWorkerFiles(directory) {
|
|
|
1046
1655
|
function isClaimedWorkerSnapshot(file) {
|
|
1047
1656
|
return path.basename(file).startsWith(CLAIMED_WORKER_SNAPSHOT_PREFIX);
|
|
1048
1657
|
}
|
|
1658
|
+
/**
|
|
1659
|
+
* The worker observations the main snapshot does not hold yet: every worker
|
|
1660
|
+
* file except a claimed one it names as compacted, which a removal left
|
|
1661
|
+
* behind.
|
|
1662
|
+
*/
|
|
1663
|
+
function uncompactedWorkerEntries(workers, main) {
|
|
1664
|
+
const compacted = new Set(main?.compacted ?? []);
|
|
1665
|
+
return workers.entries.filter((_entry, index) => !compacted.has(path.basename(workers.paths[index])));
|
|
1666
|
+
}
|
|
1049
1667
|
/**
|
|
1050
1668
|
* Read every worker snapshot file in `directory`. A file that disappears
|
|
1051
|
-
* mid-read was
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1669
|
+
* mid-read was renamed by a compactor, so the directory is listed again and the
|
|
1670
|
+
* claimed copy is read under its new name; `readable` is false when the listing
|
|
1671
|
+
* never settles. A file that exists but does not parse is reported in
|
|
1672
|
+
* `corruptPaths` so readers can degrade to a nonce and the compactor can sweep
|
|
1673
|
+
* it.
|
|
1054
1674
|
*/
|
|
1055
|
-
function readWorkerFiles(directory) {
|
|
1056
|
-
return readSnapshotFiles(directory, WORKER_SNAPSHOT_PREFIX);
|
|
1675
|
+
function readWorkerFiles(directory, operations = HOST_SNAPSHOT_READ_OPERATIONS) {
|
|
1676
|
+
return readSnapshotFiles(directory, WORKER_SNAPSHOT_PREFIX, operations);
|
|
1057
1677
|
}
|
|
1058
|
-
function readSnapshotFiles(directory, prefix) {
|
|
1678
|
+
function readSnapshotFiles(directory, prefix, operations) {
|
|
1679
|
+
// A name that vanishes between the listing and the read was renamed, not
|
|
1680
|
+
// necessarily merged: compaction claims a worker file under a new name well
|
|
1681
|
+
// before the merged main replaces the old one, so skipping the vanished name
|
|
1682
|
+
// would leave its inputs in neither place the reader looks. List again until
|
|
1683
|
+
// one pass reads every name it listed. The claimed copy then appears under
|
|
1684
|
+
// its own name, and once the merged main is published and the claimed copy
|
|
1685
|
+
// removed the main read that follows holds its content.
|
|
1686
|
+
for (let attempt = 0; attempt < SNAPSHOT_LISTING_ATTEMPTS; ++attempt) {
|
|
1687
|
+
const documents = readSnapshotListing(directory, prefix, operations);
|
|
1688
|
+
if (documents !== undefined) {
|
|
1689
|
+
return documents;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return { corruptPaths: [], entries: [], paths: [], readable: false };
|
|
1693
|
+
}
|
|
1694
|
+
/** One listing pass, or `undefined` when a listed name vanished before its read. */
|
|
1695
|
+
function readSnapshotListing(directory, prefix, operations) {
|
|
1059
1696
|
let names;
|
|
1060
1697
|
try {
|
|
1061
|
-
names =
|
|
1698
|
+
names = operations.readdirSync(directory);
|
|
1062
1699
|
}
|
|
1063
1700
|
catch (error) {
|
|
1064
1701
|
return {
|
|
@@ -1078,12 +1715,13 @@ function readSnapshotFiles(directory, prefix) {
|
|
|
1078
1715
|
const file = path.join(directory, name);
|
|
1079
1716
|
let text;
|
|
1080
1717
|
try {
|
|
1081
|
-
text =
|
|
1718
|
+
text = operations.readFileSync(file, "utf8");
|
|
1082
1719
|
}
|
|
1083
1720
|
catch (error) {
|
|
1084
|
-
if (
|
|
1085
|
-
|
|
1721
|
+
if (isMissingFileError(error)) {
|
|
1722
|
+
return undefined;
|
|
1086
1723
|
}
|
|
1724
|
+
corruptPaths.push(file);
|
|
1087
1725
|
continue;
|
|
1088
1726
|
}
|
|
1089
1727
|
const parsed = parseSnapshotDocument(text);
|
|
@@ -1102,10 +1740,10 @@ function isMissingFileError(error) {
|
|
|
1102
1740
|
"code" in error &&
|
|
1103
1741
|
error.code === "ENOENT");
|
|
1104
1742
|
}
|
|
1105
|
-
function readMainDocument(directory) {
|
|
1743
|
+
function readMainDocument(directory, operations = HOST_SNAPSHOT_READ_OPERATIONS) {
|
|
1106
1744
|
let text;
|
|
1107
1745
|
try {
|
|
1108
|
-
text =
|
|
1746
|
+
text = operations.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
|
|
1109
1747
|
}
|
|
1110
1748
|
catch {
|
|
1111
1749
|
return undefined;
|
|
@@ -1124,34 +1762,95 @@ function parseSnapshotDocument(text) {
|
|
|
1124
1762
|
return undefined;
|
|
1125
1763
|
}
|
|
1126
1764
|
const document = value;
|
|
1765
|
+
const fileSet = new Set(Array.isArray(document.files) ? document.files : []);
|
|
1127
1766
|
const keys = Object.keys(document).sort();
|
|
1128
|
-
const expectedKeys = [
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1767
|
+
const expectedKeys = [
|
|
1768
|
+
"nativePredicates",
|
|
1769
|
+
"accessibleEntries",
|
|
1770
|
+
"files",
|
|
1771
|
+
"tainted",
|
|
1772
|
+
"trees",
|
|
1773
|
+
"version",
|
|
1774
|
+
"volatile",
|
|
1775
|
+
];
|
|
1776
|
+
for (const optional of ["compacted", "id"]) {
|
|
1777
|
+
if (Object.prototype.hasOwnProperty.call(document, optional)) {
|
|
1778
|
+
expectedKeys.push(optional);
|
|
1779
|
+
}
|
|
1132
1780
|
}
|
|
1781
|
+
expectedKeys.sort();
|
|
1133
1782
|
if (stableStringify(keys) !== stableStringify(expectedKeys) ||
|
|
1134
1783
|
document.version !== SNAPSHOT_VERSION ||
|
|
1784
|
+
!isNativePredicateRequests(document.nativePredicates, fileSet) ||
|
|
1135
1785
|
!Array.isArray(document.files) ||
|
|
1136
1786
|
document.files.some((entry) => typeof entry !== "string" ||
|
|
1137
1787
|
!(path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry))) ||
|
|
1138
|
-
|
|
1788
|
+
fileSet.size !== document.files.length ||
|
|
1139
1789
|
stableStringify(document.files) !==
|
|
1140
1790
|
stableStringify([...document.files].sort()) ||
|
|
1791
|
+
!Array.isArray(document.trees) ||
|
|
1792
|
+
document.trees.some((entry) => typeof entry !== "string" || !fileSet.has(entry)) ||
|
|
1793
|
+
new Set(document.trees).size !== document.trees.length ||
|
|
1794
|
+
stableStringify(document.trees) !==
|
|
1795
|
+
stableStringify([...document.trees].sort()) ||
|
|
1796
|
+
!Array.isArray(document.accessibleEntries) ||
|
|
1797
|
+
document.accessibleEntries.some((entry) => typeof entry !== "string" || !fileSet.has(entry)) ||
|
|
1798
|
+
new Set(document.accessibleEntries).size !==
|
|
1799
|
+
document.accessibleEntries.length ||
|
|
1800
|
+
stableStringify(document.accessibleEntries) !==
|
|
1801
|
+
stableStringify([...document.accessibleEntries].sort()) ||
|
|
1141
1802
|
typeof document.tainted !== "boolean" ||
|
|
1142
1803
|
typeof document.volatile !== "boolean" ||
|
|
1143
1804
|
(document.id !== undefined &&
|
|
1144
|
-
(typeof document.id !== "string" ||
|
|
1805
|
+
(typeof document.id !== "string" ||
|
|
1806
|
+
!/^[a-f0-9]{32}$/.test(document.id))) ||
|
|
1807
|
+
(document.compacted !== undefined &&
|
|
1808
|
+
(!Array.isArray(document.compacted) ||
|
|
1809
|
+
document.compacted.length === 0 ||
|
|
1810
|
+
document.compacted.some((entry) => typeof entry !== "string" ||
|
|
1811
|
+
path.basename(entry) !== entry ||
|
|
1812
|
+
!isClaimedWorkerSnapshot(entry)) ||
|
|
1813
|
+
new Set(document.compacted).size !== document.compacted.length))) {
|
|
1145
1814
|
return undefined;
|
|
1146
1815
|
}
|
|
1147
1816
|
return {
|
|
1817
|
+
nativePredicates: document.nativePredicates,
|
|
1818
|
+
accessibleEntries: document.accessibleEntries,
|
|
1819
|
+
...(Array.isArray(document.compacted)
|
|
1820
|
+
? { compacted: document.compacted }
|
|
1821
|
+
: {}),
|
|
1148
1822
|
files: document.files,
|
|
1149
1823
|
...(typeof document.id === "string" ? { id: document.id } : {}),
|
|
1150
1824
|
tainted: document.tainted,
|
|
1825
|
+
trees: document.trees,
|
|
1151
1826
|
version: SNAPSHOT_VERSION,
|
|
1152
1827
|
volatile: document.volatile,
|
|
1153
1828
|
};
|
|
1154
1829
|
}
|
|
1830
|
+
/** Union supported per-path codec requests without retaining producer digests. */
|
|
1831
|
+
function mergeNativePredicateRequests(target, source) {
|
|
1832
|
+
for (const [file, kinds] of Object.entries(source)) {
|
|
1833
|
+
target[file] = [...new Set([...(target[file] ?? []), ...kinds])].sort();
|
|
1834
|
+
}
|
|
1835
|
+
return target;
|
|
1836
|
+
}
|
|
1837
|
+
/** Validate persisted native requests against their retained file universe. */
|
|
1838
|
+
function isNativePredicateRequests(value, files) {
|
|
1839
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
for (const [file, kinds] of Object.entries(value)) {
|
|
1843
|
+
if (!files.has(file) ||
|
|
1844
|
+
!Array.isArray(kinds) ||
|
|
1845
|
+
kinds.length === 0 ||
|
|
1846
|
+
kinds.length > 4 ||
|
|
1847
|
+
kinds.some((kind) => !["directory", "entry", "file", "optional-file"].includes(kind)) ||
|
|
1848
|
+
stableStringify(kinds) !== stableStringify([...new Set(kinds)].sort())) {
|
|
1849
|
+
return false;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return true;
|
|
1853
|
+
}
|
|
1155
1854
|
/** Write a snapshot document atomically (unique temp file, then rename). */
|
|
1156
1855
|
function writeSnapshotDocument(file, document) {
|
|
1157
1856
|
const temp = `${file}.${node_crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
@@ -1165,13 +1864,60 @@ function writeSnapshotDocument(file, document) {
|
|
|
1165
1864
|
}
|
|
1166
1865
|
}
|
|
1167
1866
|
/**
|
|
1168
|
-
* JSON-
|
|
1169
|
-
*
|
|
1867
|
+
* Represent acyclic JSON-like records with recursively sorted keys for hashing.
|
|
1868
|
+
* Arrays preserve order and JSON primitives use JSON escaping. This is an
|
|
1869
|
+
* internal key representation, not JSON for interchange.
|
|
1870
|
+
*
|
|
1871
|
+
* Undefined uses an unquoted marker and sparse array slots use a separate
|
|
1872
|
+
* unquoted hole marker, preserving cardinality and the distinction from null
|
|
1873
|
+
* and literal strings. Non-finite numbers, functions, symbols, BigInt and
|
|
1874
|
+
* cyclic structures are unsupported and throw rather than supplying a key.
|
|
1170
1875
|
* Shared with the transformer's option digest.
|
|
1876
|
+
*
|
|
1877
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1878
|
+
* Recursive Object.entries sorting and JSON primitive escaping provide a
|
|
1879
|
+
* stable internal key representation, not another transport parser. The
|
|
1880
|
+
* undefined and hole markers cannot collide with quoted string values.
|
|
1881
|
+
* Array.from visits each slot, preserving length and explicit absence.
|
|
1882
|
+
* Unsupported primitive values fail instead of aliasing null or absence.
|
|
1883
|
+
*
|
|
1884
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1885
|
+
* One recursive encoder owns array, record and primitive representations;
|
|
1886
|
+
* sorted record entries keep ordering policy in that one boundary.
|
|
1887
|
+
*
|
|
1888
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1889
|
+
* Each visited value is encoded once. Each record sorts its own keys in
|
|
1890
|
+
* O(k log k), and encoding space follows output bytes plus recursion depth.
|
|
1891
|
+
* Sorting is necessary for insertion-order-independent record keys.
|
|
1892
|
+
*
|
|
1893
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
1894
|
+
* This encoder accepts potentially mutable caller records and coordinates
|
|
1895
|
+
* no cross-call computation; consumers own whether encoded values can recur.
|
|
1896
|
+
*
|
|
1897
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
1898
|
+
* The encoder returns its owned string and retains no caller data or handle
|
|
1899
|
+
* after return; temporary recursion state is computation space.
|
|
1900
|
+
*
|
|
1901
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1902
|
+
* Absence is represented at its owning serializer rather than patched in
|
|
1903
|
+
* individual key consumers. Unsupported primitives fail explicitly.
|
|
1904
|
+
*
|
|
1905
|
+
* @evidenceExclude contracts/portability.md#os-neutral-implementation
|
|
1906
|
+
* This operation sorts and encodes caller values for a key; it has no
|
|
1907
|
+
* native path, filesystem or process boundary.
|
|
1908
|
+
*
|
|
1909
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1910
|
+
* The native JSDoc explains sorted keys, the acyclic value domain,
|
|
1911
|
+
* unsupported JSON interchange and explicit absence markers. Checked
|
|
1912
|
+
* against the documentation skill: separate paragraphs state the contract
|
|
1913
|
+
* and why its nonobvious boundary matters; field comments retain their own
|
|
1914
|
+
* useful facts.
|
|
1171
1915
|
*/
|
|
1172
1916
|
function stableStringify(value) {
|
|
1173
1917
|
if (Array.isArray(value)) {
|
|
1174
|
-
return `[${
|
|
1918
|
+
return `[${Array.from(value, (item, index) => Object.prototype.hasOwnProperty.call(value, index)
|
|
1919
|
+
? stableStringify(item)
|
|
1920
|
+
: "hole").join(",")}]`;
|
|
1175
1921
|
}
|
|
1176
1922
|
if (value !== null && typeof value === "object") {
|
|
1177
1923
|
return `{${Object.entries(value)
|
|
@@ -1179,7 +1925,16 @@ function stableStringify(value) {
|
|
|
1179
1925
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
1180
1926
|
.join(",")}}`;
|
|
1181
1927
|
}
|
|
1182
|
-
|
|
1928
|
+
if (value === undefined)
|
|
1929
|
+
return "undefined";
|
|
1930
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
1931
|
+
throw new TypeError("Cannot fingerprint a non-finite number.");
|
|
1932
|
+
}
|
|
1933
|
+
const encoded = JSON.stringify(value);
|
|
1934
|
+
if (encoded === undefined) {
|
|
1935
|
+
throw new TypeError("Cannot fingerprint an unsupported primitive value.");
|
|
1936
|
+
}
|
|
1937
|
+
return encoded;
|
|
1183
1938
|
}
|
|
1184
1939
|
|
|
1185
1940
|
exports.computeProjectFingerprint = computeProjectFingerprint;
|