@ttsc/metro 0.30.4 → 0.31.0
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/src/core/fingerprint.ts
CHANGED
|
@@ -18,16 +18,29 @@
|
|
|
18
18
|
* (samchon/ttsc#718) reports each transform's derived inputs. Workers retain
|
|
19
19
|
* them under `node_modules/.cache/ttsc-metro`, compare their generation state
|
|
20
20
|
* with the exact main-process key baseline, and batch one durable write per
|
|
21
|
-
* delivered module.
|
|
21
|
+
* delivered module. Directory-listing predicates retain their paths
|
|
22
|
+
* separately so the next static key captures the exact compiler-accessible
|
|
23
|
+
* files and directories, rather than reconstructing an earlier baseline after
|
|
24
|
+
* workers have run.
|
|
25
|
+
*
|
|
26
|
+
* Native contributor codecs retain separate per-path kind requests; their raw
|
|
27
|
+
* byte, entry and directory facts are captured anew for the next key. Schema
|
|
28
|
+
* four snapshots cannot establish those requests and restart with a fresh epoch.
|
|
22
29
|
*
|
|
23
30
|
* Snapshot layout: one main file carrying a random epoch id plus per-worker
|
|
24
31
|
* files with unique names, so concurrent workers never race a shared write.
|
|
25
32
|
* `withTtsc` compacts worker files into the main file before its workers exist,
|
|
26
33
|
* under a process-shared lock for builds using the same project cache. Readers
|
|
27
34
|
* take the union of every file, reading the worker files strictly before the
|
|
28
|
-
* main file
|
|
29
|
-
*
|
|
30
|
-
*
|
|
35
|
+
* main file. The compactor renames each worker file to a claimed name first,
|
|
36
|
+
* renames the merged main into place next, and deletes the claimed files last.
|
|
37
|
+
* A name that vanishes between a reader's listing and its read may therefore be
|
|
38
|
+
* a claim whose merge is not published yet, so the reader lists again until a
|
|
39
|
+
* pass reads every name it saw, and degrades to a nonce when the directory does
|
|
40
|
+
* not settle. A claimed file deleted after the merge is already inside the main
|
|
41
|
+
* the reader loads afterwards. A worker file the compactor could not delete
|
|
42
|
+
* stays, and the main names it as compacted, so no reader or later compaction
|
|
43
|
+
* merges it twice.
|
|
31
44
|
*
|
|
32
45
|
* Sound degradations, by design:
|
|
33
46
|
*
|
|
@@ -58,6 +71,8 @@ import {
|
|
|
58
71
|
mergeMembershipPolicyOverlay,
|
|
59
72
|
readProjectMembershipPolicy,
|
|
60
73
|
readTsconfigSourceSnapshot,
|
|
74
|
+
searchedReferencedProjects,
|
|
75
|
+
selectReferencedProject,
|
|
61
76
|
watchInputEvidenceMatchesBaseline,
|
|
62
77
|
} from "@ttsc/unplugin/api";
|
|
63
78
|
import type {
|
|
@@ -71,8 +86,11 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
71
86
|
import fs from "node:fs";
|
|
72
87
|
import path from "node:path";
|
|
73
88
|
|
|
89
|
+
import type { SnapshotReadOperations } from "./SnapshotReadOperations";
|
|
90
|
+
import type { TtscMetroProjectView } from "./TtscMetroProjectView";
|
|
91
|
+
|
|
74
92
|
/** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
|
|
75
|
-
const SNAPSHOT_VERSION =
|
|
93
|
+
const SNAPSHOT_VERSION = 5;
|
|
76
94
|
|
|
77
95
|
/** Snapshot directory segments under the fingerprint base directory. */
|
|
78
96
|
const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
|
|
@@ -89,6 +107,20 @@ const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
|
|
|
89
107
|
/** Prefix used after a compactor atomically claims an immutable worker file. */
|
|
90
108
|
const CLAIMED_WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-claimed-";
|
|
91
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Listing passes a reader repeats while a listed snapshot name keeps vanishing
|
|
112
|
+
* before it can be read. Each repeat needs another compactor to rename or
|
|
113
|
+
* remove a file, so exhausting the bound means the directory is not settling
|
|
114
|
+
* and the state cannot be proven.
|
|
115
|
+
*/
|
|
116
|
+
const SNAPSHOT_LISTING_ATTEMPTS = 8;
|
|
117
|
+
|
|
118
|
+
const HOST_SNAPSHOT_READ_OPERATIONS: SnapshotReadOperations = {
|
|
119
|
+
existsSync: (file) => fs.existsSync(file),
|
|
120
|
+
readFileSync: (file, encoding) => fs.readFileSync(file, encoding),
|
|
121
|
+
readdirSync: (directory) => fs.readdirSync(directory),
|
|
122
|
+
};
|
|
123
|
+
|
|
92
124
|
/** Directory lock serializing the one mutable main-snapshot rewrite. */
|
|
93
125
|
const SNAPSHOT_COMPACTION_LOCK = "snapshot-compaction.lock";
|
|
94
126
|
|
|
@@ -101,23 +133,56 @@ const KEY_BASELINE_PREFIX = "key-baseline-";
|
|
|
101
133
|
/** Run-token prefix that forces every transformer key to be non-reusable. */
|
|
102
134
|
const NON_REUSABLE_RUN_PREFIX = "nonce:";
|
|
103
135
|
|
|
136
|
+
/** Requested native codecs, separate from compiler-accessible entry listings. */
|
|
137
|
+
type NativeInputKind = keyof NonNullable<
|
|
138
|
+
TtscWatchInputBaseline["nativePredicates"]
|
|
139
|
+
>;
|
|
140
|
+
|
|
104
141
|
/** Union of the snapshot state readable on disk. */
|
|
105
142
|
interface SnapshotState {
|
|
143
|
+
/** Native version-one kinds to observe again for each recorded path. */
|
|
144
|
+
nativePredicates: Record<string, NativeInputKind[]>;
|
|
145
|
+
/** Paths whose compiler-visible immediate entry listings affect output. */
|
|
146
|
+
accessibleEntries: string[];
|
|
147
|
+
|
|
106
148
|
/** Random epoch id minted when the main snapshot was created. */
|
|
107
149
|
id: string;
|
|
150
|
+
|
|
108
151
|
/** Absolute paths of every recorded derived transform input. */
|
|
109
152
|
files: string[];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The recorded inputs that are plugin source directories, each also in
|
|
156
|
+
* `files`, whose state the static key carries (samchon/ttsc#1487).
|
|
157
|
+
*/
|
|
158
|
+
trees: string[];
|
|
159
|
+
|
|
110
160
|
/** Whether any recorded transform declared volatile output. */
|
|
111
161
|
volatile: boolean;
|
|
162
|
+
|
|
112
163
|
/** Whether a transform observed state different from its run's static key. */
|
|
113
164
|
tainted: boolean;
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
/** Serialized shape of the main and worker snapshot files. */
|
|
117
168
|
interface SnapshotDocument {
|
|
169
|
+
/** Native kind requests, each path also retained in files. */
|
|
170
|
+
nativePredicates: Record<string, NativeInputKind[]>;
|
|
171
|
+
/** Recorded listing predicates, each also present in files. */
|
|
172
|
+
accessibleEntries: string[];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The claimed worker files this main snapshot already holds, by name. A
|
|
176
|
+
* compaction removes each claimed file after merging it, and a removal can
|
|
177
|
+
* fail (a Windows process holding the file); what stays behind is merged
|
|
178
|
+
* again by no one. Present only on the main snapshot.
|
|
179
|
+
*/
|
|
180
|
+
compacted?: string[];
|
|
181
|
+
|
|
118
182
|
files: string[];
|
|
119
183
|
id?: string;
|
|
120
184
|
tainted: boolean;
|
|
185
|
+
trees: string[];
|
|
121
186
|
version: number;
|
|
122
187
|
volatile: boolean;
|
|
123
188
|
}
|
|
@@ -147,6 +212,40 @@ const unhealthySnapshots = new Set<string>();
|
|
|
147
212
|
* from Metro's cache-key options, the transformer from each file's transform
|
|
148
213
|
* options — all the same value in a real Metro run), else the working directory
|
|
149
214
|
* Metro was launched from.
|
|
215
|
+
*
|
|
216
|
+
* @evidence contracts/common.md#principled-implementation
|
|
217
|
+
* Node path.resolve provides one agreed base for config preparation, static
|
|
218
|
+
* keying and worker recording. A nonempty projectRoot wins; absent or empty
|
|
219
|
+
* input uses cwd.
|
|
220
|
+
*
|
|
221
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
222
|
+
* One helper defines the shared base fallback for preparation, keying and
|
|
223
|
+
* worker recording instead of giving each boundary its own anchor policy.
|
|
224
|
+
*
|
|
225
|
+
* @evidenceExclude contracts/performance.md#efficient-algorithms
|
|
226
|
+
* This operation delegates one path resolution and chooses a fallback; it
|
|
227
|
+
* owns no collection-processing strategy.
|
|
228
|
+
*
|
|
229
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
230
|
+
* The base depends on caller input and current cwd; this pure mapping
|
|
231
|
+
* coordinates no shared computation.
|
|
232
|
+
*
|
|
233
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
234
|
+
* It returns a directory string and owns no retained state or handle.
|
|
235
|
+
*
|
|
236
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
237
|
+
* It computes a directory value without changing the filesystem, foreign
|
|
238
|
+
* APIs or consumer-specific behavior.
|
|
239
|
+
*
|
|
240
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
241
|
+
* Node path.resolve and process.cwd handle native path roots and separators
|
|
242
|
+
* on supported OSes. No URL conversion or process spawning occurs.
|
|
243
|
+
*
|
|
244
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
245
|
+
* The native JSDoc explains shared base ownership and the absent/empty
|
|
246
|
+
* projectRoot fallback. Checked against the documentation skill: separate
|
|
247
|
+
* paragraphs state the contract and why its nonobvious boundary matters;
|
|
248
|
+
* field comments retain their own useful facts.
|
|
150
249
|
*/
|
|
151
250
|
export function resolveFingerprintBase(
|
|
152
251
|
projectRoot: string | undefined,
|
|
@@ -159,13 +258,53 @@ export function resolveFingerprintBase(
|
|
|
159
258
|
}
|
|
160
259
|
|
|
161
260
|
/**
|
|
162
|
-
* The directories whose walk universes the fingerprint hashes
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
261
|
+
* The directories whose walk universes the fingerprint hashes. Implicit
|
|
262
|
+
* selection searches from the base upward, so a config at the base uses that
|
|
263
|
+
* walk and a config above it replaces the base walk with its own directory,
|
|
264
|
+
* which contains the base. An explicit project inside the base uses the base
|
|
265
|
+
* walk; one above the base replaces it; one elsewhere adds its directory. The
|
|
266
|
+
* separate per-file project view can select a nested config below the base.
|
|
267
|
+
* Matching the transform core's validation universe keeps the invariant simple:
|
|
268
|
+
* everything it treats as an input is fingerprinted by the walk, the recorded
|
|
269
|
+
* snapshot, or both.
|
|
270
|
+
*
|
|
271
|
+
* @evidence contracts/common.md#principled-implementation
|
|
272
|
+
* The shared project resolver owns tsconfig selection; lexical path
|
|
273
|
+
* containment is separate from program membership.
|
|
274
|
+
* Blank explicit options mean implicit discovery. An in-root config avoids a
|
|
275
|
+
* duplicate whole-tree walk; a config above the base walks its own directory,
|
|
276
|
+
* which contains the base, instead of hashing the base subtree twice; an
|
|
277
|
+
* unrelated out-of-root config adds its directory because it can supply
|
|
278
|
+
* transform inputs.
|
|
279
|
+
*
|
|
280
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
281
|
+
* Project selection delegates to the shared resolver and containment to
|
|
282
|
+
* projectViewRoots, separating config ownership from walk-root choice.
|
|
283
|
+
*
|
|
284
|
+
* @evidenceExclude contracts/performance.md#efficient-algorithms
|
|
285
|
+
* It delegates shared project discovery and computes at most two roots;
|
|
286
|
+
* traversal cost belongs to that resolver and the fingerprint operation.
|
|
287
|
+
*
|
|
288
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
289
|
+
* This mapping retains no discovery result between calls and coordinates
|
|
290
|
+
* no completed or in-flight computation.
|
|
291
|
+
*
|
|
292
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
293
|
+
* Roots transfer to the caller; no retained state or handle is acquired.
|
|
294
|
+
*
|
|
295
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
296
|
+
* No fixture-specific roots or patched filesystem APIs are used.
|
|
297
|
+
*
|
|
298
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
299
|
+
* Node resolve and dirname handle Windows drives and POSIX roots. Lexical
|
|
300
|
+
* containment compares complete path components with only volume-root case
|
|
301
|
+
* normalization; it does not merge distinct native names by OS assumption.
|
|
302
|
+
*
|
|
303
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
304
|
+
* The native JSDoc explains walk roots, implicit selection and why outside
|
|
305
|
+
* configs extend coverage. Checked against the documentation skill:
|
|
306
|
+
* separate paragraphs state the contract and why its nonobvious boundary
|
|
307
|
+
* matters; field comments retain their own useful facts.
|
|
169
308
|
*/
|
|
170
309
|
export function fingerprintRoots(
|
|
171
310
|
base: string,
|
|
@@ -194,45 +333,18 @@ function projectViewRoots(
|
|
|
194
333
|
// the whole project twice on every cache key (samchon/ttsc#1307).
|
|
195
334
|
const resolvedBase = path.resolve(base);
|
|
196
335
|
const directory = path.dirname(path.resolve(tsconfig));
|
|
197
|
-
const
|
|
198
|
-
const inside =
|
|
199
|
-
relative === "" ||
|
|
200
|
-
(relative !== ".." &&
|
|
201
|
-
!relative.startsWith(`..${path.sep}`) &&
|
|
202
|
-
!path.isAbsolute(relative));
|
|
336
|
+
const inside = pathIsWithin(directory, resolvedBase);
|
|
203
337
|
if (explicitProject === undefined && inside && directory !== resolvedBase) {
|
|
204
338
|
return [directory];
|
|
205
339
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
* wrong exactly when those two describe different projects. Passing them
|
|
215
|
-
* separately made that mismatch expressible — the policy for one project
|
|
216
|
-
* alongside the root of another — and passing the policy alone made it
|
|
217
|
-
* expressible in a quieter way still, since a recorder that resolved its own
|
|
218
|
-
* could describe a different program than the walk hashed. Both halves travel
|
|
219
|
-
* together so neither can be supplied without the other (samchon/ttsc#1316).
|
|
220
|
-
*/
|
|
221
|
-
export interface TtscMetroProjectView {
|
|
222
|
-
/** The base directory both fingerprint sides agree on. */
|
|
223
|
-
readonly base: string;
|
|
224
|
-
/** Config candidates observed while selecting this transform's project. */
|
|
225
|
-
readonly discoveryInputs: readonly TtscWatchInput[];
|
|
226
|
-
/** The caller's explicit `project`, if any. */
|
|
227
|
-
readonly explicitProject: string | undefined;
|
|
228
|
-
/** The membership policy resolved for that project. */
|
|
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;
|
|
340
|
+
if (inside) return [resolvedBase];
|
|
341
|
+
// A config above the base owns a walk that already contains the base
|
|
342
|
+
// subtree under the same membership policy, so walking the base as well would
|
|
343
|
+
// hash every file below it twice on each key. A config beside or elsewhere
|
|
344
|
+
// does not contain the base, so both roots stay.
|
|
345
|
+
return pathIsWithin(resolvedBase, directory)
|
|
346
|
+
? [directory]
|
|
347
|
+
: [resolvedBase, directory];
|
|
236
348
|
}
|
|
237
349
|
|
|
238
350
|
/** One stable implicit-project view and the config graph that produced it. */
|
|
@@ -249,9 +361,57 @@ interface TtscMetroFingerprintProjectMap {
|
|
|
249
361
|
/**
|
|
250
362
|
* Resolve one transform's project view, once, for every watch input it reports.
|
|
251
363
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
364
|
+
* Discovery, referenced-project selection and membership policy are resolved
|
|
365
|
+
* once per delivered module and shared by its recorder batch. The policy is
|
|
366
|
+
* read from source rather than trusting a cached mtime/size observation, which
|
|
367
|
+
* could miss same-stamp edits.
|
|
368
|
+
*
|
|
369
|
+
* @evidence contracts/common.md#principled-implementation
|
|
370
|
+
* Shared project discovery, reference selection and membership-policy APIs
|
|
371
|
+
* select the same config as the transform core, retaining positive and
|
|
372
|
+
* negative discovery predicates. Consulted routing configs retain current
|
|
373
|
+
* content, since their references or admission rules can change the owner.
|
|
374
|
+
* Compiler-option overlays are merged through that owner.
|
|
375
|
+
*
|
|
376
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
377
|
+
* Selection records the predicates that chose the project, then one
|
|
378
|
+
* createProjectView helper assembles its policy and roots for every input
|
|
379
|
+
* in the module's recorder batch.
|
|
380
|
+
*
|
|
381
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
382
|
+
* Discovery follows the ancestor candidates and reference graph; the
|
|
383
|
+
* resulting policy and roots are assembled once for the delivered module.
|
|
384
|
+
* Candidate predicates and consulted config content are captured once for
|
|
385
|
+
* this view, with a lexical-key map merging their overlapping paths in
|
|
386
|
+
* linear map operations. Capturing content performs the shared baseline's
|
|
387
|
+
* two observations and scales with the consulted config bytes.
|
|
388
|
+
*
|
|
389
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
390
|
+
* The returned view is reused by the module's recorder batch; its selected
|
|
391
|
+
* tsconfig is passed to the transform options. Config sources are reread for
|
|
392
|
+
* a later module because matching mtime and size cannot establish continued
|
|
393
|
+
* equivalence.
|
|
394
|
+
*
|
|
395
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
396
|
+
* Returned project data transfers to the caller. This operation retains no
|
|
397
|
+
* process-wide state or handle after discovery completes.
|
|
398
|
+
*
|
|
399
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
400
|
+
* The view is resolved once per module and shared with its batch, avoiding
|
|
401
|
+
* independent recorder discovery without hardcoded project or test names.
|
|
402
|
+
*
|
|
403
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
404
|
+
* Node absolute paths and host discovery predicates retain native
|
|
405
|
+
* filesystem identity. The injected discovery filesystem is an explicit
|
|
406
|
+
* observation boundary, and path-relative helpers respect native roots; no
|
|
407
|
+
* shell command or global filesystem patch is used.
|
|
408
|
+
*
|
|
409
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
410
|
+
* The native JSDoc explains selection inputs, returned coupled view,
|
|
411
|
+
* discovery evidence and per-module reuse. Checked against the
|
|
412
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
413
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
414
|
+
* facts.
|
|
255
415
|
*/
|
|
256
416
|
export function resolveProjectView(props: {
|
|
257
417
|
compilerOptions?: Record<string, unknown>;
|
|
@@ -270,10 +430,15 @@ export function resolveProjectView(props: {
|
|
|
270
430
|
explicitProject === undefined
|
|
271
431
|
? discoverNearestProjectTsconfig(start, props.projectDiscoveryFilesystem)
|
|
272
432
|
: undefined;
|
|
273
|
-
const
|
|
433
|
+
const nearest =
|
|
274
434
|
discovery === undefined
|
|
275
435
|
? resolveProjectTsconfig(start, explicitProject)
|
|
276
436
|
: (discovery.file ?? path.resolve(process.cwd(), "tsconfig.json"));
|
|
437
|
+
const selection =
|
|
438
|
+
explicitProject === undefined && props.filename !== undefined
|
|
439
|
+
? selectReferencedProject(path.resolve(props.filename), nearest)
|
|
440
|
+
: { consulted: [], tsconfig: nearest };
|
|
441
|
+
const tsconfig = selection.tsconfig;
|
|
277
442
|
const discoveryInputs =
|
|
278
443
|
discovery === undefined
|
|
279
444
|
? [captureProjectDiscoveryInput(tsconfig)]
|
|
@@ -283,6 +448,32 @@ export function resolveProjectView(props: {
|
|
|
283
448
|
if (!discoveryInputs.some((input) => samePath(input.file, tsconfig))) {
|
|
284
449
|
discoveryInputs.push(captureProjectDiscoveryInput(tsconfig));
|
|
285
450
|
}
|
|
451
|
+
const discoveryIndexes = new Map(
|
|
452
|
+
discoveryInputs.map((input, index) => [snapshotPathKey(input.file), index]),
|
|
453
|
+
);
|
|
454
|
+
for (const config of selection.consulted) {
|
|
455
|
+
const baseline = captureWatchInputBaseline(config);
|
|
456
|
+
const input: TtscWatchInput =
|
|
457
|
+
baseline === undefined
|
|
458
|
+
? { file: config }
|
|
459
|
+
: {
|
|
460
|
+
evidence: {
|
|
461
|
+
identity: baseline.identity,
|
|
462
|
+
missing: !baseline.fileExists,
|
|
463
|
+
state: { codec: "host", hash: baseline.hostHash },
|
|
464
|
+
...(!baseline.fileExists
|
|
465
|
+
? { unavailable: "missing" as const }
|
|
466
|
+
: {}),
|
|
467
|
+
},
|
|
468
|
+
file: config,
|
|
469
|
+
};
|
|
470
|
+
const key = snapshotPathKey(config);
|
|
471
|
+
const index = discoveryIndexes.get(key);
|
|
472
|
+
if (index === undefined) {
|
|
473
|
+
discoveryIndexes.set(key, discoveryInputs.length);
|
|
474
|
+
discoveryInputs.push(input);
|
|
475
|
+
} else discoveryInputs[index] = input;
|
|
476
|
+
}
|
|
286
477
|
return createProjectView({
|
|
287
478
|
base,
|
|
288
479
|
compilerOptions: props.compilerOptions,
|
|
@@ -428,6 +619,30 @@ function fingerprintProjectViews(props: {
|
|
|
428
619
|
),
|
|
429
620
|
);
|
|
430
621
|
}
|
|
622
|
+
// A solution config routes its files to the projects it references, and the
|
|
623
|
+
// worker compiles those (samchon/ttsc#1397), so their configs and inputs
|
|
624
|
+
// key the cache as well. The solution's own snapshot covers its list.
|
|
625
|
+
for (const project of [...projects]) {
|
|
626
|
+
for (const referenced of searchedReferencedProjects(project.tsconfig)) {
|
|
627
|
+
if (
|
|
628
|
+
!fs.existsSync(referenced) ||
|
|
629
|
+
projects.some((known) => samePath(known.tsconfig, referenced))
|
|
630
|
+
) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
projects.push(
|
|
634
|
+
stableFingerprintProjectView(
|
|
635
|
+
createProjectView({
|
|
636
|
+
base: primary.base,
|
|
637
|
+
compilerOptions: props.compilerOptions,
|
|
638
|
+
explicitProject: undefined,
|
|
639
|
+
tsconfig: referenced,
|
|
640
|
+
}),
|
|
641
|
+
props.compilerOptions,
|
|
642
|
+
),
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
431
646
|
const secondMap = findProjectTsconfigs(
|
|
432
647
|
primary.base,
|
|
433
648
|
props.projectDiscoveryFilesystem,
|
|
@@ -520,19 +735,20 @@ function sameProjectMap(
|
|
|
520
735
|
);
|
|
521
736
|
}
|
|
522
737
|
|
|
523
|
-
/**
|
|
738
|
+
/** Equality of resolved lexical spellings, preserving distinct native names. */
|
|
524
739
|
function samePath(left: string, right: string): boolean {
|
|
525
|
-
return
|
|
740
|
+
return snapshotPathKey(left) === snapshotPathKey(right);
|
|
526
741
|
}
|
|
527
742
|
|
|
528
743
|
/** Whether one resolved path lies at or below another. */
|
|
529
744
|
function pathIsWithin(child: string, parent: string): boolean {
|
|
530
|
-
const
|
|
745
|
+
const childKey = snapshotPathKey(child);
|
|
746
|
+
const parentKey = snapshotPathKey(parent);
|
|
531
747
|
return (
|
|
532
|
-
|
|
533
|
-
(
|
|
534
|
-
|
|
535
|
-
|
|
748
|
+
childKey === parentKey ||
|
|
749
|
+
childKey.startsWith(
|
|
750
|
+
parentKey.endsWith(path.sep) ? parentKey : `${parentKey}${path.sep}`,
|
|
751
|
+
)
|
|
536
752
|
);
|
|
537
753
|
}
|
|
538
754
|
|
|
@@ -540,13 +756,73 @@ function pathIsWithin(child: string, parent: string): boolean {
|
|
|
540
756
|
* Compute the fingerprint `getCacheKey` folds into Metro's static transformer
|
|
541
757
|
* key. Never throws: any failure degrades to a nonce, which soundly disables
|
|
542
758
|
* cross-run cache reuse for this run instead of serving stale output.
|
|
759
|
+
*
|
|
760
|
+
* A successful call makes two complete observations of the routed projects,
|
|
761
|
+
* config sources and recorded inputs. Work scales with their files and bytes;
|
|
762
|
+
* it is paid at Metro's static-key boundary, not once per delivered module. The
|
|
763
|
+
* run baseline retains exact hashes, identities and discovery predicates so a
|
|
764
|
+
* worker can prove that its generation matches the state keyed here.
|
|
765
|
+
*
|
|
766
|
+
* @evidence contracts/common.md#principled-implementation
|
|
767
|
+
* The shared project walker/policy and input-state APIs supply real file,
|
|
768
|
+
* config, identity and recorded-input observations. Two complete
|
|
769
|
+
* observations must agree before Node sha256 publishes the key and immutable
|
|
770
|
+
* run baseline. Missing, volatile, incomplete or changing state yields
|
|
771
|
+
* randomBytes nonce, a required withdrawal of reuse rather than a successful
|
|
772
|
+
* stale fallback. Injected discovery changes observation only, not
|
|
773
|
+
* production results.
|
|
774
|
+
*
|
|
775
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
776
|
+
* The entry point compares two observations and publishes their baseline;
|
|
777
|
+
* observeProjectFingerprint owns collecting the complete input universe.
|
|
778
|
+
* Nonreusable state has one nonce exit rather than partial-success keys.
|
|
779
|
+
*
|
|
780
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
781
|
+
* Two observations scan project files and recorded inputs, with time driven
|
|
782
|
+
* by their bytes and config graph size. Overlapping lexical project walks
|
|
783
|
+
* are excluded by routed roots; maps deduplicate config sources. Exact
|
|
784
|
+
* content observations are necessary because timestamps do not prove state.
|
|
785
|
+
*
|
|
786
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
787
|
+
* The static key shares transforms only for equal options, config contents,
|
|
788
|
+
* file hashes, identities, recorded generation state and requested exact
|
|
789
|
+
* compiler-accessible entry listings. Volatile, tainted
|
|
790
|
+
* or unstable observations take a fresh nonce; a run baseline must be
|
|
791
|
+
* immutable before workers can claim its coverage.
|
|
792
|
+
*
|
|
793
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
794
|
+
* Observation maps are local to key computation and are released on return.
|
|
795
|
+
* The persisted run baseline is owned by snapshot preparation, which sweeps
|
|
796
|
+
* old baselines. A long-running worker can retain its loaded baseline until
|
|
797
|
+
* worker termination; it owns no open handle here.
|
|
798
|
+
*
|
|
799
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
800
|
+
* Decision values come from the documented inputs and product protocol
|
|
801
|
+
* rather than expected test answers. No compensating path is introduced to
|
|
802
|
+
* make a known example pass.
|
|
803
|
+
*
|
|
804
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
805
|
+
* Node paths/fs preserve native roots and physical identities, including
|
|
806
|
+
* symlink-aware discovery. Sorted key representation is deterministic; file
|
|
807
|
+
* bytes and host predicates remain inputs. Filesystem failure withdraws
|
|
808
|
+
* reuse consistently instead of becoming an OS-specific successful key.
|
|
809
|
+
*
|
|
810
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
811
|
+
* The native JSDoc explains hex digest versus nonce, repeated observation,
|
|
812
|
+
* private baseline publication and failure policy. Checked against the
|
|
813
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
814
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
815
|
+
* facts.
|
|
543
816
|
*/
|
|
544
817
|
export function computeProjectFingerprint(props: {
|
|
545
818
|
compilerOptions?: Record<string, unknown>;
|
|
546
819
|
explicitProject?: string;
|
|
820
|
+
|
|
547
821
|
/** Test seam for proving that incomplete implicit enumeration fails closed. */
|
|
548
822
|
projectDiscoveryFilesystem?: TtscProjectTreeDiscoveryFilesystem;
|
|
823
|
+
|
|
549
824
|
projectRoot?: string;
|
|
825
|
+
|
|
550
826
|
/** Private identity transported from `withTtsc` to this Metro run. */
|
|
551
827
|
runId?: string;
|
|
552
828
|
}): string {
|
|
@@ -644,12 +920,40 @@ function observeProjectFingerprint(props: {
|
|
|
644
920
|
if (snapshot === undefined || snapshot.volatile || snapshot.tainted) {
|
|
645
921
|
throw new Error("Metro's recorded transform snapshot is not reusable.");
|
|
646
922
|
}
|
|
647
|
-
|
|
923
|
+
// A plugin's Go source is a recorded input like any other, but no one
|
|
924
|
+
// path's state stands for the files below it, so the key carries its state
|
|
925
|
+
// (samchon/ttsc#1487).
|
|
926
|
+
const trees = new Set(snapshot.trees);
|
|
927
|
+
const accessibleEntries = new Set(snapshot.accessibleEntries);
|
|
928
|
+
const recorded: Record<
|
|
929
|
+
string,
|
|
930
|
+
{
|
|
931
|
+
hash: string;
|
|
932
|
+
identity: string;
|
|
933
|
+
tree?: string | null;
|
|
934
|
+
accessibleEntries?: TtscWatchInputBaseline["accessibleEntries"];
|
|
935
|
+
nativePredicates?: TtscWatchInputBaseline["nativePredicates"];
|
|
936
|
+
}
|
|
937
|
+
> = {};
|
|
648
938
|
for (const file of snapshot.files) {
|
|
649
|
-
const baseline = addBaselineInput(
|
|
939
|
+
const baseline = addBaselineInput(
|
|
940
|
+
inputs,
|
|
941
|
+
file,
|
|
942
|
+
undefined,
|
|
943
|
+
trees.has(file),
|
|
944
|
+
accessibleEntries.has(file),
|
|
945
|
+
snapshot.nativePredicates[file],
|
|
946
|
+
);
|
|
650
947
|
recorded[snapshotPathKey(file)] = {
|
|
651
948
|
hash: baseline.hostHash,
|
|
652
949
|
identity: baseline.identity,
|
|
950
|
+
...(baseline.tree === undefined ? {} : { tree: baseline.tree }),
|
|
951
|
+
...(baseline.accessibleEntries === undefined
|
|
952
|
+
? {}
|
|
953
|
+
: { accessibleEntries: baseline.accessibleEntries }),
|
|
954
|
+
...(baseline.nativePredicates === undefined
|
|
955
|
+
? {}
|
|
956
|
+
: { nativePredicates: baseline.nativePredicates }),
|
|
653
957
|
};
|
|
654
958
|
}
|
|
655
959
|
return {
|
|
@@ -665,33 +969,83 @@ function observeProjectFingerprint(props: {
|
|
|
665
969
|
};
|
|
666
970
|
}
|
|
667
971
|
|
|
668
|
-
/**
|
|
972
|
+
/**
|
|
973
|
+
* Add one lexical path's stable broad state to a key baseline, with its plugin
|
|
974
|
+
* source state when it was recorded as a plugin source directory (`tree`).
|
|
975
|
+
* Recorded listing predicates request exact immediate accessible entries; these
|
|
976
|
+
* optional payloads merge only after their shared broad state agrees. Native
|
|
977
|
+
* kind requests capture the raw producer codec independently of those listings.
|
|
978
|
+
* The returned state includes every payload already observed at the same key,
|
|
979
|
+
* so a later alias cannot erase an earlier payload from the fingerprint.
|
|
980
|
+
*/
|
|
669
981
|
function addBaselineInput(
|
|
670
982
|
inputs: Record<string, TtscWatchInputKeyBaseline>,
|
|
671
983
|
file: string,
|
|
672
984
|
staticInputs?: Set<string>,
|
|
985
|
+
tree = false,
|
|
986
|
+
accessibleEntries = false,
|
|
987
|
+
nativePredicates?: readonly NativeInputKind[],
|
|
673
988
|
): TtscWatchInputBaseline {
|
|
674
989
|
const key = snapshotPathKey(file);
|
|
675
|
-
const observed = captureWatchInputBaseline(file
|
|
990
|
+
const observed = captureWatchInputBaseline(file, undefined, {
|
|
991
|
+
tree,
|
|
992
|
+
accessibleEntries,
|
|
993
|
+
nativePredicates,
|
|
994
|
+
});
|
|
676
995
|
if (observed === undefined) {
|
|
677
996
|
throw new Error("Unable to read a stable Metro input baseline.");
|
|
678
997
|
}
|
|
679
998
|
const existing = inputs[key];
|
|
999
|
+
let baseline = observed;
|
|
680
1000
|
if (existing !== undefined) {
|
|
1001
|
+
// One path observed as a plugin source and as a plain input compares on
|
|
1002
|
+
// what both observations carry.
|
|
1003
|
+
const {
|
|
1004
|
+
tree: existingTree,
|
|
1005
|
+
accessibleEntries: existingEntries,
|
|
1006
|
+
nativePredicates: existingNative,
|
|
1007
|
+
...existingState
|
|
1008
|
+
} = existing as TtscWatchInputBaseline;
|
|
1009
|
+
const {
|
|
1010
|
+
tree: observedTree,
|
|
1011
|
+
accessibleEntries: observedEntries,
|
|
1012
|
+
nativePredicates: observedNative,
|
|
1013
|
+
...observedState
|
|
1014
|
+
} = observed;
|
|
681
1015
|
if (
|
|
1016
|
+
Object.entries(existingNative ?? {}).some(
|
|
1017
|
+
([kind, state]) =>
|
|
1018
|
+
observedNative?.[kind as NativeInputKind] !== undefined &&
|
|
1019
|
+
stableStringify(state) !==
|
|
1020
|
+
stableStringify(observedNative[kind as NativeInputKind]),
|
|
1021
|
+
) ||
|
|
682
1022
|
existing.identity !== observed.identity ||
|
|
683
1023
|
existing.fileExists !== observed.fileExists ||
|
|
684
1024
|
("hostHash" in existing &&
|
|
685
|
-
stableStringify(
|
|
1025
|
+
stableStringify(existingState) !== stableStringify(observedState)) ||
|
|
1026
|
+
(existingTree !== undefined &&
|
|
1027
|
+
observedTree !== undefined &&
|
|
1028
|
+
existingTree !== observedTree) ||
|
|
1029
|
+
(existingEntries !== undefined &&
|
|
1030
|
+
observedEntries !== undefined &&
|
|
1031
|
+
(stableStringify(existingEntries.files) !==
|
|
1032
|
+
stableStringify(observedEntries.files) ||
|
|
1033
|
+
stableStringify(existingEntries.directories) !==
|
|
1034
|
+
stableStringify(observedEntries.directories)))
|
|
686
1035
|
) {
|
|
687
1036
|
throw new Error("A Metro input changed between baseline observations.");
|
|
688
1037
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1038
|
+
baseline = {
|
|
1039
|
+
...existing,
|
|
1040
|
+
...observed,
|
|
1041
|
+
...(existingNative === undefined && observedNative === undefined
|
|
1042
|
+
? {}
|
|
1043
|
+
: { nativePredicates: { ...existingNative, ...observedNative } }),
|
|
1044
|
+
};
|
|
692
1045
|
}
|
|
1046
|
+
inputs[key] = baseline;
|
|
693
1047
|
staticInputs?.add(key);
|
|
694
|
-
return
|
|
1048
|
+
return baseline;
|
|
695
1049
|
}
|
|
696
1050
|
|
|
697
1051
|
/** Add the stable file predicate used by the project-map traversal. */
|
|
@@ -741,14 +1095,71 @@ function nonce(): string {
|
|
|
741
1095
|
* token crosses bundle and process boundaries, so `getCacheKey` degrades to a
|
|
742
1096
|
* nonce even when neither snapshot location can persist the failure and an old
|
|
743
1097
|
* main later reappears.
|
|
1098
|
+
*
|
|
1099
|
+
* Compaction reads the accumulated worker and recovery documents and unions
|
|
1100
|
+
* their recorded paths. Workers have unique names; completed observations are
|
|
1101
|
+
* published before removal. Locked claimed files remain until a later retry.
|
|
1102
|
+
* Old temporary files and run baselines are swept, but recorded input paths
|
|
1103
|
+
* accumulate in the main snapshot and dead-owner election directories remain to
|
|
1104
|
+
* prevent delayed contenders from moving a newer lock. Those populations are
|
|
1105
|
+
* not bounded by a fixed retention budget in this implementation.
|
|
1106
|
+
*
|
|
1107
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1108
|
+
* Node filesystem APIs atomically publish complete snapshot documents and a
|
|
1109
|
+
* token-owned compaction lock. Worker claims are merged before deletion;
|
|
1110
|
+
* retained claims are identified in the main document to prevent replay.
|
|
1111
|
+
* Definite dead-PID recovery preserves an election record; contention or
|
|
1112
|
+
* failed persistence returns a private nonreusable token.
|
|
1113
|
+
*
|
|
1114
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1115
|
+
* Preparation owns one lock-protected main rewrite. Helpers separate worker
|
|
1116
|
+
* claims, document parsing, atomic publication and recovery storage, so
|
|
1117
|
+
* concurrent readers can use the same persisted protocol.
|
|
1118
|
+
*
|
|
1119
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1120
|
+
* Sets union recorded paths in time proportional to document paths, then
|
|
1121
|
+
* sorting gives deterministic output. Each claimed worker is merged once;
|
|
1122
|
+
* readdir and document parsing remain necessary to find pending publishers.
|
|
1123
|
+
*
|
|
1124
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
1125
|
+
* The main document records claimed worker names before removal, preventing
|
|
1126
|
+
* locked files from replaying completed compaction. The epoch survives only
|
|
1127
|
+
* trusted, untainted observations; corrupt or lost evidence rotates it.
|
|
1128
|
+
*
|
|
1129
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
1130
|
+
* Preparation owns its compaction lock and retires it in finally. Claimed
|
|
1131
|
+
* workers and recovery files are removed after publication; failed removals
|
|
1132
|
+
* stay represented for retry. Temporary files and baselines have age sweeps,
|
|
1133
|
+
* but cumulative input paths and stale-owner election records have no fixed
|
|
1134
|
+
* bound. Removing these without a generation protocol could erase evidence.
|
|
1135
|
+
*
|
|
1136
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1137
|
+
* Retries serve locked-file/recovery contracts rather than masking an
|
|
1138
|
+
* uncorrected successful-state assumption.
|
|
1139
|
+
*
|
|
1140
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
1141
|
+
* Node join, mkdir, rename and rm implement native cache paths.
|
|
1142
|
+
* Windows-locked files remain represented for a later removal attempt; only
|
|
1143
|
+
* a definite process-query absence permits dead-owner recovery. No shell
|
|
1144
|
+
* process checks or patched fs methods are used.
|
|
1145
|
+
*
|
|
1146
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1147
|
+
* The native JSDoc explains new-run preparation, contention, epoch
|
|
1148
|
+
* rotation, recovery storage and nonreusable tokens. Checked against the
|
|
1149
|
+
* documentation skill: separate paragraphs state the contract and why its
|
|
1150
|
+
* nonobvious boundary matters; field comments retain their own useful
|
|
1151
|
+
* facts.
|
|
744
1152
|
*/
|
|
745
1153
|
export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
746
1154
|
const base = resolveFingerprintBase(projectRoot);
|
|
747
1155
|
const runId = randomBytes(16).toString("hex");
|
|
748
1156
|
let reusable = true;
|
|
749
1157
|
let pending: SnapshotDocument = {
|
|
1158
|
+
nativePredicates: {},
|
|
1159
|
+
accessibleEntries: [],
|
|
750
1160
|
files: [],
|
|
751
1161
|
tainted: false,
|
|
1162
|
+
trees: [],
|
|
752
1163
|
version: SNAPSHOT_VERSION,
|
|
753
1164
|
volatile: false,
|
|
754
1165
|
};
|
|
@@ -770,9 +1181,8 @@ export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
|
770
1181
|
return `${NON_REUSABLE_RUN_PREFIX}${runId}`;
|
|
771
1182
|
}
|
|
772
1183
|
// Read the worker files strictly before the main file (see the module doc
|
|
773
|
-
// comment)
|
|
774
|
-
//
|
|
775
|
-
// is already inside the main read below.
|
|
1184
|
+
// comment). The lock keeps another compactor from claiming or deleting a
|
|
1185
|
+
// file during these reads, and a vanished name still forces a fresh listing.
|
|
776
1186
|
claimWorkerFiles(directory);
|
|
777
1187
|
const recovery = readUnhealthySnapshots(base);
|
|
778
1188
|
const workers = readWorkerFiles(directory);
|
|
@@ -781,7 +1191,23 @@ export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
|
781
1191
|
}
|
|
782
1192
|
const main = readMainDocument(directory);
|
|
783
1193
|
const files = new Set(main?.files ?? []);
|
|
784
|
-
const
|
|
1194
|
+
const trees = new Set(main?.trees ?? []);
|
|
1195
|
+
const accessibleEntries = new Set(main?.accessibleEntries ?? []);
|
|
1196
|
+
const nativePredicates = mergeNativePredicateRequests(
|
|
1197
|
+
{},
|
|
1198
|
+
main?.nativePredicates ?? {},
|
|
1199
|
+
);
|
|
1200
|
+
const observations = [
|
|
1201
|
+
...recovery.entries,
|
|
1202
|
+
...uncompactedWorkerEntries(workers, main),
|
|
1203
|
+
];
|
|
1204
|
+
// Every claimed file present is recorded before any is removed, so one
|
|
1205
|
+
// whose removal fails, or a compactor that stops between the two, leaves a
|
|
1206
|
+
// file the next compaction knows it already merged.
|
|
1207
|
+
const compacted = workers.paths
|
|
1208
|
+
.filter(isClaimedWorkerSnapshot)
|
|
1209
|
+
.map((file) => path.basename(file))
|
|
1210
|
+
.sort();
|
|
785
1211
|
const tainted = observations.some((entry) => entry.tainted);
|
|
786
1212
|
const volatile =
|
|
787
1213
|
observations.length === 0
|
|
@@ -791,21 +1217,32 @@ export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
|
791
1217
|
// able to clear the sticky flag.
|
|
792
1218
|
observations.some((entry) => entry.volatile);
|
|
793
1219
|
for (const entry of observations) {
|
|
1220
|
+
mergeNativePredicateRequests(nativePredicates, entry.nativePredicates);
|
|
794
1221
|
for (const file of entry.files) {
|
|
795
1222
|
files.add(file);
|
|
796
1223
|
}
|
|
1224
|
+
for (const tree of entry.trees) {
|
|
1225
|
+
trees.add(tree);
|
|
1226
|
+
}
|
|
1227
|
+
for (const file of entry.accessibleEntries) {
|
|
1228
|
+
accessibleEntries.add(file);
|
|
1229
|
+
}
|
|
797
1230
|
}
|
|
798
1231
|
const recovering =
|
|
799
1232
|
unhealthySnapshots.has(base) ||
|
|
800
1233
|
recovery.paths.length !== 0 ||
|
|
801
1234
|
recovery.corruptPaths.length !== 0;
|
|
802
1235
|
pending = {
|
|
1236
|
+
nativePredicates,
|
|
1237
|
+
accessibleEntries: [...accessibleEntries].sort(),
|
|
1238
|
+
...(compacted.length !== 0 ? { compacted } : {}),
|
|
803
1239
|
files: [...files].sort(),
|
|
804
1240
|
id:
|
|
805
1241
|
!recovering && workers.corruptPaths.length === 0 && !tainted
|
|
806
1242
|
? (main?.id ?? randomBytes(16).toString("hex"))
|
|
807
1243
|
: randomBytes(16).toString("hex"),
|
|
808
1244
|
tainted: false,
|
|
1245
|
+
trees: [...trees].sort(),
|
|
809
1246
|
version: SNAPSHOT_VERSION,
|
|
810
1247
|
volatile,
|
|
811
1248
|
};
|
|
@@ -821,8 +1258,8 @@ export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
|
821
1258
|
try {
|
|
822
1259
|
fs.rmSync(file, { force: true });
|
|
823
1260
|
} catch {
|
|
824
|
-
// A locked
|
|
825
|
-
//
|
|
1261
|
+
// A locked file stays for a later compaction. The main snapshot names
|
|
1262
|
+
// claimed workers it merged, so readers do not replay them.
|
|
826
1263
|
}
|
|
827
1264
|
}
|
|
828
1265
|
const remainingRecovery = readUnhealthySnapshots(base);
|
|
@@ -865,6 +1302,14 @@ export function prepareSnapshot(projectRoot: string | undefined): string {
|
|
|
865
1302
|
* while holding a reusable run identity. A contending process therefore
|
|
866
1303
|
* degrades immediately to a nonce. The owner retires the directory atomically
|
|
867
1304
|
* after every success or failure path has persisted its verdict.
|
|
1305
|
+
*
|
|
1306
|
+
* A lock is reaped only when its recorded process is proven gone. An owner that
|
|
1307
|
+
* died and whose process id now belongs to a live process, or a lock with an
|
|
1308
|
+
* unreadable owner record, is treated as held: every run that meets it takes a
|
|
1309
|
+
* nonce key and no reuse is lost silently, but the cache stays unusable until
|
|
1310
|
+
* that process exits or
|
|
1311
|
+
* `node_modules/.cache/ttsc-metro/snapshot-compaction.lock` is deleted by hand.
|
|
1312
|
+
* Readers also treat a held lock as unsettled state.
|
|
868
1313
|
*/
|
|
869
1314
|
function acquireSnapshotCompactionLock(
|
|
870
1315
|
directory: string,
|
|
@@ -1024,14 +1469,116 @@ function listExpiredKeyBaselines(directory: string): string[] {
|
|
|
1024
1469
|
|
|
1025
1470
|
/**
|
|
1026
1471
|
* Read the unioned snapshot state, or `undefined` when the main snapshot is
|
|
1027
|
-
* missing
|
|
1028
|
-
* recorded set cannot be trusted, so the caller degrades to a nonce)
|
|
1472
|
+
* missing, any snapshot file is corrupt (a torn or foreign write means the
|
|
1473
|
+
* recorded set cannot be trusted, so the caller degrades to a nonce), or a
|
|
1474
|
+
* concurrent compaction keeps renaming worker files so the listing never
|
|
1475
|
+
* settles or a compaction lock stays held.
|
|
1476
|
+
*
|
|
1477
|
+
* `operations` is the read boundary (existence, listing, file text); the
|
|
1478
|
+
* default reads the real filesystem, and a test passes its own to interleave a
|
|
1479
|
+
* compaction with a listing. A pass is retried at most
|
|
1480
|
+
* {@link SNAPSHOT_LISTING_ATTEMPTS} (8) times with a 5 ms pause between passes,
|
|
1481
|
+
* so a held lock costs a call at most about 40 ms before the state is reported
|
|
1482
|
+
* untrusted; the retry is bounded and holds no resource between passes.
|
|
1483
|
+
*
|
|
1484
|
+
* The result contains sorted absolute file and tree paths, the epoch identity,
|
|
1485
|
+
* and tainted/volatile flags. It reads persisted evidence without revalidating
|
|
1486
|
+
* current file bytes; the fingerprint operation owns that validation. Reading
|
|
1487
|
+
* costs the number and size of outstanding documents plus their unioned paths.
|
|
1488
|
+
*
|
|
1489
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1490
|
+
* Strict snapshot parsing and Set union read worker documents before the
|
|
1491
|
+
* main publication, retaining file/tree/volatile/tainted state without
|
|
1492
|
+
* replaying already compacted claims. This is the owned persisted-input
|
|
1493
|
+
* protocol, not a separate compiler dependency model.
|
|
1494
|
+
* A pass is accepted only when no compaction lock existed before or after
|
|
1495
|
+
* it and the main file is unchanged, because a listing concurrent with a
|
|
1496
|
+
* rename can return the renamed entry under neither name.
|
|
1497
|
+
* The read boundary is an optional parameter, so the interleaving can be
|
|
1498
|
+
* driven deterministically while production reads the real filesystem.
|
|
1499
|
+
*
|
|
1500
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1501
|
+
* This reader validates persisted documents and unions their paths without
|
|
1502
|
+
* recomputing file state. The fingerprint owner handles live validation;
|
|
1503
|
+
* both reader and compactor share uncompacted-worker classification.
|
|
1504
|
+
*
|
|
1505
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1506
|
+
* Reading costs document bytes plus Set insertion and final path sorting.
|
|
1507
|
+
* The compacted-name Set avoids replay and avoids a linear search per worker.
|
|
1508
|
+
*
|
|
1509
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
1510
|
+
* This read must observe new worker publications; it coordinates no cache
|
|
1511
|
+
* between calls. Cross-run reuse belongs to computeProjectFingerprint.
|
|
1512
|
+
*
|
|
1513
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
1514
|
+
* Returned snapshot data transfers to the caller. This reader retains no
|
|
1515
|
+
* state or handle after synchronous reads finish.
|
|
1516
|
+
*
|
|
1517
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1518
|
+
* Unknown, corrupt or recovery-pending state returns undefined, never an
|
|
1519
|
+
* invented complete empty set.
|
|
1520
|
+
*
|
|
1521
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
1522
|
+
* Node path and fs read native snapshot locations. Locked or unreadable
|
|
1523
|
+
* directory/documents invalidate the proof on all supported OSes; sorted
|
|
1524
|
+
* lexical paths remain values, not shell arguments.
|
|
1525
|
+
*
|
|
1526
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1527
|
+
* The native JSDoc explains unioned snapshot fields, read ordering and the
|
|
1528
|
+
* undefined result for untrusted state. Checked against the documentation
|
|
1529
|
+
* skill: separate paragraphs state the contract and why its nonobvious
|
|
1530
|
+
* boundary matters; field comments retain their own useful facts.
|
|
1029
1531
|
*/
|
|
1030
|
-
export function readSnapshotState(
|
|
1532
|
+
export function readSnapshotState(
|
|
1533
|
+
base: string,
|
|
1534
|
+
operations: SnapshotReadOperations = HOST_SNAPSHOT_READ_OPERATIONS,
|
|
1535
|
+
): SnapshotState | undefined {
|
|
1536
|
+
const directory = snapshotDirectory(base);
|
|
1537
|
+
const lock = path.join(directory, SNAPSHOT_COMPACTION_LOCK);
|
|
1538
|
+
// A directory listing taken while another process renames entries of that
|
|
1539
|
+
// same directory is not guaranteed to return each renamed entry once: POSIX
|
|
1540
|
+
// leaves it unspecified and NTFS orders entries by name, so a claimed worker
|
|
1541
|
+
// file can appear under neither name, with its inputs not yet in the main
|
|
1542
|
+
// file. A compaction holds the lock from its first claim until after it
|
|
1543
|
+
// publishes the main file, so a pass counts only when no lock existed before
|
|
1544
|
+
// or after it and the main file read the same on both sides. A compaction
|
|
1545
|
+
// wholly inside the pass still changes the main file it publishes.
|
|
1546
|
+
for (let attempt = 0; attempt < SNAPSHOT_LISTING_ATTEMPTS; ++attempt) {
|
|
1547
|
+
if (!operations.existsSync(lock)) {
|
|
1548
|
+
const before = readMainText(directory, operations);
|
|
1549
|
+
const state = readSnapshotStateOnce(base, operations);
|
|
1550
|
+
if (
|
|
1551
|
+
!operations.existsSync(lock) &&
|
|
1552
|
+
readMainText(directory, operations) === before
|
|
1553
|
+
) {
|
|
1554
|
+
return state;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
1558
|
+
}
|
|
1559
|
+
return undefined;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** The main snapshot's text, or `undefined` when it cannot be read. */
|
|
1563
|
+
function readMainText(
|
|
1564
|
+
directory: string,
|
|
1565
|
+
operations: SnapshotReadOperations,
|
|
1566
|
+
): string | undefined {
|
|
1567
|
+
try {
|
|
1568
|
+
return operations.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
|
|
1569
|
+
} catch {
|
|
1570
|
+
return undefined;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function readSnapshotStateOnce(
|
|
1575
|
+
base: string,
|
|
1576
|
+
operations: SnapshotReadOperations,
|
|
1577
|
+
): SnapshotState | undefined {
|
|
1031
1578
|
if (unhealthySnapshots.has(base)) {
|
|
1032
1579
|
return undefined;
|
|
1033
1580
|
}
|
|
1034
|
-
const recovery = readUnhealthySnapshots(base);
|
|
1581
|
+
const recovery = readUnhealthySnapshots(base, operations);
|
|
1035
1582
|
if (
|
|
1036
1583
|
!recovery.readable ||
|
|
1037
1584
|
recovery.paths.length !== 0 ||
|
|
@@ -1041,25 +1588,46 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
|
|
|
1041
1588
|
}
|
|
1042
1589
|
const directory = snapshotDirectory(base);
|
|
1043
1590
|
// Worker files strictly before the main file — see the module doc comment.
|
|
1044
|
-
const workers = readWorkerFiles(directory);
|
|
1591
|
+
const workers = readWorkerFiles(directory, operations);
|
|
1045
1592
|
if (!workers.readable || workers.corruptPaths.length !== 0) {
|
|
1046
1593
|
return undefined;
|
|
1047
1594
|
}
|
|
1048
|
-
const main = readMainDocument(directory);
|
|
1595
|
+
const main = readMainDocument(directory, operations);
|
|
1049
1596
|
if (main === undefined || typeof main.id !== "string") {
|
|
1050
1597
|
return undefined;
|
|
1051
1598
|
}
|
|
1052
1599
|
const files = new Set(main.files);
|
|
1600
|
+
const trees = new Set(main.trees);
|
|
1601
|
+
const accessibleEntries = new Set(main.accessibleEntries);
|
|
1602
|
+
const nativePredicates = mergeNativePredicateRequests(
|
|
1603
|
+
{},
|
|
1604
|
+
main.nativePredicates,
|
|
1605
|
+
);
|
|
1053
1606
|
let volatile = main.volatile;
|
|
1054
1607
|
let tainted = main.tainted;
|
|
1055
|
-
for (const entry of workers
|
|
1608
|
+
for (const entry of uncompactedWorkerEntries(workers, main)) {
|
|
1609
|
+
mergeNativePredicateRequests(nativePredicates, entry.nativePredicates);
|
|
1056
1610
|
for (const file of entry.files) {
|
|
1057
1611
|
files.add(file);
|
|
1058
1612
|
}
|
|
1613
|
+
for (const tree of entry.trees) {
|
|
1614
|
+
trees.add(tree);
|
|
1615
|
+
}
|
|
1616
|
+
for (const file of entry.accessibleEntries) {
|
|
1617
|
+
accessibleEntries.add(file);
|
|
1618
|
+
}
|
|
1059
1619
|
volatile ||= entry.volatile;
|
|
1060
1620
|
tainted ||= entry.tainted;
|
|
1061
1621
|
}
|
|
1062
|
-
return {
|
|
1622
|
+
return {
|
|
1623
|
+
nativePredicates,
|
|
1624
|
+
accessibleEntries: [...accessibleEntries].sort(),
|
|
1625
|
+
files: [...files].sort(),
|
|
1626
|
+
id: main.id,
|
|
1627
|
+
tainted,
|
|
1628
|
+
trees: [...trees].sort(),
|
|
1629
|
+
volatile,
|
|
1630
|
+
};
|
|
1063
1631
|
}
|
|
1064
1632
|
|
|
1065
1633
|
/**
|
|
@@ -1067,13 +1635,87 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
|
|
|
1067
1635
|
* any volatile declaration, compares compiler-generation evidence with the
|
|
1068
1636
|
* matching main-process run baseline, and marks any temporal mismatch tainted.
|
|
1069
1637
|
* A clean transform also writes a document so it can clear a volatile
|
|
1070
|
-
* declaration from an earlier run.
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1638
|
+
* declaration from an earlier run. The first delivery, a delivery that adds a
|
|
1639
|
+
* path, and every delivery once the state is tainted flush one cumulative
|
|
1640
|
+
* document; the unique name makes worker writes race-free, and `withTtsc`
|
|
1641
|
+
* compacts the files on the next run.
|
|
1642
|
+
*
|
|
1643
|
+
* `record` accepts one lexical path without generation evidence; `recordMany`
|
|
1644
|
+
* accepts a module's generation evidence; `recordVolatile` withdraws the file
|
|
1645
|
+
* proof for non-file inputs. Without a run identity every input is retained;
|
|
1646
|
+
* production passes the identity whose immutable baseline was keyed in the main
|
|
1647
|
+
* process. An unknown or mismatching baseline marks the snapshot tainted, and
|
|
1648
|
+
* since `record` carries no evidence, a run identity always reads it as a
|
|
1649
|
+
* mismatch. Listing predicates retain their paths for the next run's key
|
|
1650
|
+
* observation; native kind requests survive the same persistence path without
|
|
1651
|
+
* becoming compiler listings or gaining watch scope. The worker never adds a
|
|
1652
|
+
* new disk read to its earlier immutable baseline.
|
|
1653
|
+
*
|
|
1654
|
+
* Sets and baseline maps live for the recorder's worker lifetime and grow with
|
|
1655
|
+
* observed projects and distinct inputs. A flushing delivery serializes the
|
|
1656
|
+
* cumulative recorded set once, not once per input, but a tainted state flushes
|
|
1657
|
+
* on every delivery, so its cost grows with the recorded set for each module. A
|
|
1658
|
+
* failed write remains dirty for retry and tries recovery storage. If both
|
|
1659
|
+
* stores fail, a reusable run throws rather than publishing output backed by
|
|
1660
|
+
* lost evidence.
|
|
1661
|
+
*
|
|
1662
|
+
* @evidence contracts/common.md#principled-implementation
|
|
1663
|
+
* One worker closure retains derived inputs and compares their
|
|
1664
|
+
* compiler-generation evidence with its immutable run baseline through
|
|
1665
|
+
* shared validation APIs. Static coverage is omitted only after a matching
|
|
1666
|
+
* proof; unknown/volatile inputs persist taint.
|
|
1667
|
+
* Listing and plugin-tree paths stay in the retained file universe even
|
|
1668
|
+
* when a static proof already covers them, so optional payloads survive
|
|
1669
|
+
* subsequent compaction and key capture.
|
|
1670
|
+
* A first or changed module batch publishes one cumulative atomic document,
|
|
1671
|
+
* with recovery storage and AggregateError when a reusable generation cannot
|
|
1672
|
+
* persist either record.
|
|
1673
|
+
*
|
|
1674
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
1675
|
+
* One worker closure owns recorder state; shared recordMany owns evidence
|
|
1676
|
+
* comparison and batch publication for single and multiple inputs. flush
|
|
1677
|
+
* owns durability, so volatile observations use the same failure policy.
|
|
1678
|
+
*
|
|
1679
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
1680
|
+
* Sets insert distinct inputs and a cached static-input Set checks coverage
|
|
1681
|
+
* without searching the whole baseline. A flushing module serializes the
|
|
1682
|
+
* cumulative paths once, costing their count and encoded bytes rather than
|
|
1683
|
+
* one cumulative serialization for every input. A tainted state flushes on
|
|
1684
|
+
* every module, so that cost recurs per delivery until the worker ends.
|
|
1685
|
+
*
|
|
1686
|
+
* @evidence contracts/performance.md#reuse-equivalent-work
|
|
1687
|
+
* Each base loads its immutable run baseline once and shares it across
|
|
1688
|
+
* module batches. Only matching generation evidence authorizes omission of
|
|
1689
|
+
* static paths; missing or differing evidence records taint instead.
|
|
1690
|
+
*
|
|
1691
|
+
* @evidence contracts/performance.md#bound-retention-and-release-resources
|
|
1692
|
+
* The worker owns maps of bases, baseline bytes and distinct recorded paths
|
|
1693
|
+
* until termination. Population grows with projects and inputs observed in
|
|
1694
|
+
* that worker; there is no fixed eviction budget. Persisted files transfer
|
|
1695
|
+
* to the next preparation's compactor. No native handle remains open.
|
|
1696
|
+
*
|
|
1697
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
1698
|
+
* The legacy no-run handshake records all paths without claiming static
|
|
1699
|
+
* coverage.
|
|
1700
|
+
*
|
|
1701
|
+
* @evidence contracts/portability.md#os-neutral-implementation
|
|
1702
|
+
* Node resolve/join and atomic rename use native paths; unique PID/random
|
|
1703
|
+
* worker names separate publishers. Windows-locked claims remain readable
|
|
1704
|
+
* until later compaction, and persistence failures follow the same reuse
|
|
1705
|
+
* withdrawal contract. No foreign filesystem or process method is replaced.
|
|
1706
|
+
*
|
|
1707
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
1708
|
+
* The native JSDoc explains record, batch and volatile operations,
|
|
1709
|
+
* run-baseline matching, cumulative persistence and failure ownership.
|
|
1710
|
+
* Checked against the documentation skill: separate paragraphs state the
|
|
1711
|
+
* contract and why its nonobvious boundary matters; field comments retain
|
|
1712
|
+
* their own useful facts.
|
|
1073
1713
|
*/
|
|
1074
1714
|
export function createSnapshotRecorder(runId?: string): {
|
|
1715
|
+
/** Record one lexical input without claiming compiler-generation evidence. */
|
|
1075
1716
|
record: (props: {
|
|
1076
1717
|
input: string;
|
|
1718
|
+
|
|
1077
1719
|
/**
|
|
1078
1720
|
* The project this input belongs to, with the policy its walk uses,
|
|
1079
1721
|
* resolved once per transform through {@link resolveProjectView}.
|
|
@@ -1091,18 +1733,25 @@ export function createSnapshotRecorder(runId?: string): {
|
|
|
1091
1733
|
*/
|
|
1092
1734
|
project: TtscMetroProjectView;
|
|
1093
1735
|
}) => void;
|
|
1736
|
+
|
|
1737
|
+
/** Record one module's input evidence and publish its cumulative snapshot. */
|
|
1094
1738
|
recordMany: (props: {
|
|
1095
1739
|
inputs: readonly TtscWatchInput[];
|
|
1096
1740
|
project: TtscMetroProjectView;
|
|
1097
1741
|
}) => void;
|
|
1742
|
+
|
|
1743
|
+
/** Persist that a project's output depends on inputs a file key cannot prove. */
|
|
1098
1744
|
recordVolatile: (props: { project: TtscMetroProjectView }) => void;
|
|
1099
1745
|
} {
|
|
1100
1746
|
const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
1101
1747
|
interface BaseState {
|
|
1748
|
+
nativePredicates: Record<string, NativeInputKind[]>;
|
|
1749
|
+
accessibleEntries: Set<string>;
|
|
1102
1750
|
dirty: boolean;
|
|
1103
1751
|
files: Set<string>;
|
|
1104
1752
|
observed: boolean;
|
|
1105
1753
|
tainted: boolean;
|
|
1754
|
+
trees: Set<string>;
|
|
1106
1755
|
volatile: boolean;
|
|
1107
1756
|
}
|
|
1108
1757
|
const states = new Map<string, BaseState>();
|
|
@@ -1151,10 +1800,13 @@ export function createSnapshotRecorder(runId?: string): {
|
|
|
1151
1800
|
let state = states.get(base);
|
|
1152
1801
|
if (state === undefined) {
|
|
1153
1802
|
state = {
|
|
1803
|
+
nativePredicates: {},
|
|
1804
|
+
accessibleEntries: new Set(),
|
|
1154
1805
|
dirty: false,
|
|
1155
1806
|
files: new Set(),
|
|
1156
1807
|
observed: false,
|
|
1157
1808
|
tainted: false,
|
|
1809
|
+
trees: new Set(),
|
|
1158
1810
|
volatile: false,
|
|
1159
1811
|
};
|
|
1160
1812
|
states.set(base, state);
|
|
@@ -1167,8 +1819,11 @@ export function createSnapshotRecorder(runId?: string): {
|
|
|
1167
1819
|
return;
|
|
1168
1820
|
}
|
|
1169
1821
|
const document: SnapshotDocument = {
|
|
1822
|
+
nativePredicates: state.nativePredicates,
|
|
1823
|
+
accessibleEntries: [...state.accessibleEntries].sort(),
|
|
1170
1824
|
files: [...state.files].sort(),
|
|
1171
1825
|
tainted: state.tainted,
|
|
1826
|
+
trees: [...state.trees].sort(),
|
|
1172
1827
|
version: SNAPSHOT_VERSION,
|
|
1173
1828
|
volatile: state.volatile,
|
|
1174
1829
|
};
|
|
@@ -1220,6 +1875,37 @@ export function createSnapshotRecorder(runId?: string): {
|
|
|
1220
1875
|
state.files.add(file);
|
|
1221
1876
|
state.dirty = true;
|
|
1222
1877
|
}
|
|
1878
|
+
// A plugin source directory is recorded as one, so the next run's key
|
|
1879
|
+
// carries its state (samchon/ttsc#1487).
|
|
1880
|
+
if (input.evidence?.state?.codec === "tree" && !state.trees.has(file)) {
|
|
1881
|
+
state.files.add(file);
|
|
1882
|
+
state.trees.add(file);
|
|
1883
|
+
state.dirty = true;
|
|
1884
|
+
}
|
|
1885
|
+
if (
|
|
1886
|
+
input.evidence?.state?.codec === "predicates" &&
|
|
1887
|
+
input.evidence.state.observation.accessibleEntries !== undefined &&
|
|
1888
|
+
!state.accessibleEntries.has(file)
|
|
1889
|
+
) {
|
|
1890
|
+
state.files.add(file);
|
|
1891
|
+
state.accessibleEntries.add(file);
|
|
1892
|
+
state.dirty = true;
|
|
1893
|
+
}
|
|
1894
|
+
if (input.evidence?.state?.codec === "predicates") {
|
|
1895
|
+
const predicates = input.evidence.state.observation.nativePredicates;
|
|
1896
|
+
if (predicates !== undefined && predicates.length !== 0) {
|
|
1897
|
+
const previous = state.nativePredicates[file] ?? [];
|
|
1898
|
+
const kinds = [...new Set([
|
|
1899
|
+
...previous,
|
|
1900
|
+
...predicates.map((predicate) => predicate.kind),
|
|
1901
|
+
])].sort();
|
|
1902
|
+
if (kinds.length !== previous.length) {
|
|
1903
|
+
state.files.add(file);
|
|
1904
|
+
state.nativePredicates[file] = kinds;
|
|
1905
|
+
state.dirty = true;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1223
1909
|
}
|
|
1224
1910
|
// A clean empty delivery must still clear a volatile verdict from the
|
|
1225
1911
|
// preceding run. Persist once for the whole module, not once per input.
|
|
@@ -1251,10 +1937,17 @@ export function createSnapshotRecorder(runId?: string): {
|
|
|
1251
1937
|
};
|
|
1252
1938
|
}
|
|
1253
1939
|
|
|
1254
|
-
/**
|
|
1940
|
+
/**
|
|
1941
|
+
* Lexical spelling used by main and worker processes. Native name case stays
|
|
1942
|
+
* exact because an OS name cannot establish directory case sensitivity. A
|
|
1943
|
+
* differently spelled alias can miss coverage and taint a run, but cannot erase
|
|
1944
|
+
* a distinct input. Only the Windows volume root is case-normalized.
|
|
1945
|
+
*/
|
|
1255
1946
|
function snapshotPathKey(file: string): string {
|
|
1256
1947
|
const resolved = path.resolve(file);
|
|
1257
|
-
|
|
1948
|
+
if (process.platform !== "win32") return resolved;
|
|
1949
|
+
const root = path.parse(resolved).root;
|
|
1950
|
+
return `${root.toLowerCase()}${resolved.slice(root.length)}`;
|
|
1258
1951
|
}
|
|
1259
1952
|
|
|
1260
1953
|
/** Persist the exact filesystem state one run's static key observed. */
|
|
@@ -1374,10 +2067,14 @@ function persistUnhealthySnapshot(
|
|
|
1374
2067
|
);
|
|
1375
2068
|
}
|
|
1376
2069
|
|
|
1377
|
-
function readUnhealthySnapshots(
|
|
2070
|
+
function readUnhealthySnapshots(
|
|
2071
|
+
base: string,
|
|
2072
|
+
operations: SnapshotReadOperations = HOST_SNAPSHOT_READ_OPERATIONS,
|
|
2073
|
+
): SnapshotDocuments {
|
|
1378
2074
|
return readSnapshotFiles(
|
|
1379
2075
|
snapshotCacheDirectory(base),
|
|
1380
2076
|
UNHEALTHY_SNAPSHOT_PREFIX,
|
|
2077
|
+
operations,
|
|
1381
2078
|
);
|
|
1382
2079
|
}
|
|
1383
2080
|
|
|
@@ -1427,28 +2124,71 @@ function isClaimedWorkerSnapshot(file: string): boolean {
|
|
|
1427
2124
|
return path.basename(file).startsWith(CLAIMED_WORKER_SNAPSHOT_PREFIX);
|
|
1428
2125
|
}
|
|
1429
2126
|
|
|
2127
|
+
/**
|
|
2128
|
+
* The worker observations the main snapshot does not hold yet: every worker
|
|
2129
|
+
* file except a claimed one it names as compacted, which a removal left
|
|
2130
|
+
* behind.
|
|
2131
|
+
*/
|
|
2132
|
+
function uncompactedWorkerEntries(
|
|
2133
|
+
workers: SnapshotDocuments,
|
|
2134
|
+
main: SnapshotDocument | undefined,
|
|
2135
|
+
): SnapshotDocument[] {
|
|
2136
|
+
const compacted = new Set(main?.compacted ?? []);
|
|
2137
|
+
return workers.entries.filter(
|
|
2138
|
+
(_entry, index) => !compacted.has(path.basename(workers.paths[index]!)),
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
1430
2142
|
/**
|
|
1431
2143
|
* Read every worker snapshot file in `directory`. A file that disappears
|
|
1432
|
-
* mid-read was
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
2144
|
+
* mid-read was renamed by a compactor, so the directory is listed again and the
|
|
2145
|
+
* claimed copy is read under its new name; `readable` is false when the listing
|
|
2146
|
+
* never settles. A file that exists but does not parse is reported in
|
|
2147
|
+
* `corruptPaths` so readers can degrade to a nonce and the compactor can sweep
|
|
2148
|
+
* it.
|
|
1435
2149
|
*/
|
|
1436
|
-
function readWorkerFiles(
|
|
2150
|
+
function readWorkerFiles(
|
|
2151
|
+
directory: string,
|
|
2152
|
+
operations: SnapshotReadOperations = HOST_SNAPSHOT_READ_OPERATIONS,
|
|
2153
|
+
): {
|
|
1437
2154
|
corruptPaths: string[];
|
|
1438
2155
|
entries: SnapshotDocument[];
|
|
1439
2156
|
paths: string[];
|
|
1440
2157
|
readable: boolean;
|
|
1441
2158
|
} {
|
|
1442
|
-
return readSnapshotFiles(directory, WORKER_SNAPSHOT_PREFIX);
|
|
2159
|
+
return readSnapshotFiles(directory, WORKER_SNAPSHOT_PREFIX, operations);
|
|
1443
2160
|
}
|
|
1444
2161
|
|
|
1445
2162
|
function readSnapshotFiles(
|
|
1446
2163
|
directory: string,
|
|
1447
2164
|
prefix: string,
|
|
2165
|
+
operations: SnapshotReadOperations,
|
|
1448
2166
|
): SnapshotDocuments {
|
|
2167
|
+
// A name that vanishes between the listing and the read was renamed, not
|
|
2168
|
+
// necessarily merged: compaction claims a worker file under a new name well
|
|
2169
|
+
// before the merged main replaces the old one, so skipping the vanished name
|
|
2170
|
+
// would leave its inputs in neither place the reader looks. List again until
|
|
2171
|
+
// one pass reads every name it listed. The claimed copy then appears under
|
|
2172
|
+
// its own name, and once the merged main is published and the claimed copy
|
|
2173
|
+
// removed the main read that follows holds its content.
|
|
2174
|
+
for (let attempt = 0; attempt < SNAPSHOT_LISTING_ATTEMPTS; ++attempt) {
|
|
2175
|
+
const documents = readSnapshotListing(directory, prefix, operations);
|
|
2176
|
+
if (documents !== undefined) {
|
|
2177
|
+
return documents;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
return { corruptPaths: [], entries: [], paths: [], readable: false };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** One listing pass, or `undefined` when a listed name vanished before its read. */
|
|
2184
|
+
function readSnapshotListing(
|
|
2185
|
+
directory: string,
|
|
2186
|
+
prefix: string,
|
|
2187
|
+
operations: SnapshotReadOperations,
|
|
2188
|
+
): SnapshotDocuments | undefined {
|
|
1449
2189
|
let names: string[];
|
|
1450
2190
|
try {
|
|
1451
|
-
names =
|
|
2191
|
+
names = operations.readdirSync(directory);
|
|
1452
2192
|
} catch (error) {
|
|
1453
2193
|
return {
|
|
1454
2194
|
corruptPaths: [],
|
|
@@ -1467,11 +2207,12 @@ function readSnapshotFiles(
|
|
|
1467
2207
|
const file = path.join(directory, name);
|
|
1468
2208
|
let text: string;
|
|
1469
2209
|
try {
|
|
1470
|
-
text =
|
|
2210
|
+
text = operations.readFileSync(file, "utf8");
|
|
1471
2211
|
} catch (error) {
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
2212
|
+
if (isMissingFileError(error)) {
|
|
2213
|
+
return undefined;
|
|
1474
2214
|
}
|
|
2215
|
+
corruptPaths.push(file);
|
|
1475
2216
|
continue;
|
|
1476
2217
|
}
|
|
1477
2218
|
const parsed = parseSnapshotDocument(text);
|
|
@@ -1494,10 +2235,13 @@ function isMissingFileError(error: unknown): boolean {
|
|
|
1494
2235
|
);
|
|
1495
2236
|
}
|
|
1496
2237
|
|
|
1497
|
-
function readMainDocument(
|
|
2238
|
+
function readMainDocument(
|
|
2239
|
+
directory: string,
|
|
2240
|
+
operations: SnapshotReadOperations = HOST_SNAPSHOT_READ_OPERATIONS,
|
|
2241
|
+
): SnapshotDocument | undefined {
|
|
1498
2242
|
let text: string;
|
|
1499
2243
|
try {
|
|
1500
|
-
text =
|
|
2244
|
+
text = operations.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
|
|
1501
2245
|
} catch {
|
|
1502
2246
|
return undefined;
|
|
1503
2247
|
}
|
|
@@ -1515,40 +2259,117 @@ function parseSnapshotDocument(text: string): SnapshotDocument | undefined {
|
|
|
1515
2259
|
return undefined;
|
|
1516
2260
|
}
|
|
1517
2261
|
const document = value as Record<string, unknown>;
|
|
2262
|
+
const fileSet = new Set(Array.isArray(document.files) ? document.files : []);
|
|
1518
2263
|
const keys = Object.keys(document).sort();
|
|
1519
|
-
const expectedKeys = [
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
2264
|
+
const expectedKeys = [
|
|
2265
|
+
"nativePredicates",
|
|
2266
|
+
"accessibleEntries",
|
|
2267
|
+
"files",
|
|
2268
|
+
"tainted",
|
|
2269
|
+
"trees",
|
|
2270
|
+
"version",
|
|
2271
|
+
"volatile",
|
|
2272
|
+
];
|
|
2273
|
+
for (const optional of ["compacted", "id"]) {
|
|
2274
|
+
if (Object.prototype.hasOwnProperty.call(document, optional)) {
|
|
2275
|
+
expectedKeys.push(optional);
|
|
2276
|
+
}
|
|
1523
2277
|
}
|
|
2278
|
+
expectedKeys.sort();
|
|
1524
2279
|
if (
|
|
1525
2280
|
stableStringify(keys) !== stableStringify(expectedKeys) ||
|
|
1526
2281
|
document.version !== SNAPSHOT_VERSION ||
|
|
2282
|
+
!isNativePredicateRequests(document.nativePredicates, fileSet) ||
|
|
1527
2283
|
!Array.isArray(document.files) ||
|
|
1528
2284
|
document.files.some(
|
|
1529
2285
|
(entry) =>
|
|
1530
2286
|
typeof entry !== "string" ||
|
|
1531
2287
|
!(path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry)),
|
|
1532
2288
|
) ||
|
|
1533
|
-
|
|
2289
|
+
fileSet.size !== document.files.length ||
|
|
1534
2290
|
stableStringify(document.files) !==
|
|
1535
2291
|
stableStringify([...document.files].sort()) ||
|
|
2292
|
+
!Array.isArray(document.trees) ||
|
|
2293
|
+
document.trees.some(
|
|
2294
|
+
(entry) => typeof entry !== "string" || !fileSet.has(entry),
|
|
2295
|
+
) ||
|
|
2296
|
+
new Set(document.trees).size !== document.trees.length ||
|
|
2297
|
+
stableStringify(document.trees) !==
|
|
2298
|
+
stableStringify([...document.trees].sort()) ||
|
|
2299
|
+
!Array.isArray(document.accessibleEntries) ||
|
|
2300
|
+
document.accessibleEntries.some(
|
|
2301
|
+
(entry) => typeof entry !== "string" || !fileSet.has(entry),
|
|
2302
|
+
) ||
|
|
2303
|
+
new Set(document.accessibleEntries).size !==
|
|
2304
|
+
document.accessibleEntries.length ||
|
|
2305
|
+
stableStringify(document.accessibleEntries) !==
|
|
2306
|
+
stableStringify([...document.accessibleEntries].sort()) ||
|
|
1536
2307
|
typeof document.tainted !== "boolean" ||
|
|
1537
2308
|
typeof document.volatile !== "boolean" ||
|
|
1538
2309
|
(document.id !== undefined &&
|
|
1539
|
-
(typeof document.id !== "string" ||
|
|
2310
|
+
(typeof document.id !== "string" ||
|
|
2311
|
+
!/^[a-f0-9]{32}$/.test(document.id))) ||
|
|
2312
|
+
(document.compacted !== undefined &&
|
|
2313
|
+
(!Array.isArray(document.compacted) ||
|
|
2314
|
+
document.compacted.length === 0 ||
|
|
2315
|
+
document.compacted.some(
|
|
2316
|
+
(entry) =>
|
|
2317
|
+
typeof entry !== "string" ||
|
|
2318
|
+
path.basename(entry) !== entry ||
|
|
2319
|
+
!isClaimedWorkerSnapshot(entry),
|
|
2320
|
+
) ||
|
|
2321
|
+
new Set(document.compacted).size !== document.compacted.length))
|
|
1540
2322
|
) {
|
|
1541
2323
|
return undefined;
|
|
1542
2324
|
}
|
|
1543
2325
|
return {
|
|
2326
|
+
nativePredicates: document.nativePredicates as Record<string, NativeInputKind[]>,
|
|
2327
|
+
accessibleEntries: document.accessibleEntries as string[],
|
|
2328
|
+
...(Array.isArray(document.compacted)
|
|
2329
|
+
? { compacted: document.compacted as string[] }
|
|
2330
|
+
: {}),
|
|
1544
2331
|
files: document.files as string[],
|
|
1545
2332
|
...(typeof document.id === "string" ? { id: document.id } : {}),
|
|
1546
2333
|
tainted: document.tainted,
|
|
2334
|
+
trees: document.trees as string[],
|
|
1547
2335
|
version: SNAPSHOT_VERSION,
|
|
1548
2336
|
volatile: document.volatile,
|
|
1549
2337
|
};
|
|
1550
2338
|
}
|
|
1551
2339
|
|
|
2340
|
+
/** Union supported per-path codec requests without retaining producer digests. */
|
|
2341
|
+
function mergeNativePredicateRequests(
|
|
2342
|
+
target: Record<string, NativeInputKind[]>,
|
|
2343
|
+
source: Record<string, NativeInputKind[]>,
|
|
2344
|
+
): Record<string, NativeInputKind[]> {
|
|
2345
|
+
for (const [file, kinds] of Object.entries(source)) {
|
|
2346
|
+
target[file] = [...new Set([...(target[file] ?? []), ...kinds])].sort();
|
|
2347
|
+
}
|
|
2348
|
+
return target;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** Validate persisted native requests against their retained file universe. */
|
|
2352
|
+
function isNativePredicateRequests(value: unknown, files: Set<unknown>): boolean {
|
|
2353
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
2354
|
+
return false;
|
|
2355
|
+
}
|
|
2356
|
+
for (const [file, kinds] of Object.entries(value)) {
|
|
2357
|
+
if (
|
|
2358
|
+
!files.has(file) ||
|
|
2359
|
+
!Array.isArray(kinds) ||
|
|
2360
|
+
kinds.length === 0 ||
|
|
2361
|
+
kinds.length > 4 ||
|
|
2362
|
+
kinds.some((kind) =>
|
|
2363
|
+
!["directory", "entry", "file", "optional-file"].includes(kind),
|
|
2364
|
+
) ||
|
|
2365
|
+
stableStringify(kinds) !== stableStringify([...new Set(kinds)].sort())
|
|
2366
|
+
) {
|
|
2367
|
+
return false;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return true;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
1552
2373
|
/** Write a snapshot document atomically (unique temp file, then rename). */
|
|
1553
2374
|
function writeSnapshotDocument(
|
|
1554
2375
|
file: string,
|
|
@@ -1565,13 +2386,62 @@ function writeSnapshotDocument(
|
|
|
1565
2386
|
}
|
|
1566
2387
|
|
|
1567
2388
|
/**
|
|
1568
|
-
* JSON-
|
|
1569
|
-
*
|
|
2389
|
+
* Represent acyclic JSON-like records with recursively sorted keys for hashing.
|
|
2390
|
+
* Arrays preserve order and JSON primitives use JSON escaping. This is an
|
|
2391
|
+
* internal key representation, not JSON for interchange.
|
|
2392
|
+
*
|
|
2393
|
+
* Undefined uses an unquoted marker and sparse array slots use a separate
|
|
2394
|
+
* unquoted hole marker, preserving cardinality and the distinction from null
|
|
2395
|
+
* and literal strings. Non-finite numbers, functions, symbols, BigInt and
|
|
2396
|
+
* cyclic structures are unsupported and throw rather than supplying a key.
|
|
1570
2397
|
* Shared with the transformer's option digest.
|
|
2398
|
+
*
|
|
2399
|
+
* @evidence contracts/common.md#principled-implementation
|
|
2400
|
+
* Recursive Object.entries sorting and JSON primitive escaping provide a
|
|
2401
|
+
* stable internal key representation, not another transport parser. The
|
|
2402
|
+
* undefined and hole markers cannot collide with quoted string values.
|
|
2403
|
+
* Array.from visits each slot, preserving length and explicit absence.
|
|
2404
|
+
* Unsupported primitive values fail instead of aliasing null or absence.
|
|
2405
|
+
*
|
|
2406
|
+
* @evidence contracts/common.md#clear-and-simple-design
|
|
2407
|
+
* One recursive encoder owns array, record and primitive representations;
|
|
2408
|
+
* sorted record entries keep ordering policy in that one boundary.
|
|
2409
|
+
*
|
|
2410
|
+
* @evidence contracts/performance.md#efficient-algorithms
|
|
2411
|
+
* Each visited value is encoded once. Each record sorts its own keys in
|
|
2412
|
+
* O(k log k), and encoding space follows output bytes plus recursion depth.
|
|
2413
|
+
* Sorting is necessary for insertion-order-independent record keys.
|
|
2414
|
+
*
|
|
2415
|
+
* @evidenceExclude contracts/performance.md#reuse-equivalent-work
|
|
2416
|
+
* This encoder accepts potentially mutable caller records and coordinates
|
|
2417
|
+
* no cross-call computation; consumers own whether encoded values can recur.
|
|
2418
|
+
*
|
|
2419
|
+
* @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
|
|
2420
|
+
* The encoder returns its owned string and retains no caller data or handle
|
|
2421
|
+
* after return; temporary recursion state is computation space.
|
|
2422
|
+
*
|
|
2423
|
+
* @evidence contracts/common.md#prohibited-implementation-shortcuts
|
|
2424
|
+
* Absence is represented at its owning serializer rather than patched in
|
|
2425
|
+
* individual key consumers. Unsupported primitives fail explicitly.
|
|
2426
|
+
*
|
|
2427
|
+
* @evidenceExclude contracts/portability.md#os-neutral-implementation
|
|
2428
|
+
* This operation sorts and encodes caller values for a key; it has no
|
|
2429
|
+
* native path, filesystem or process boundary.
|
|
2430
|
+
*
|
|
2431
|
+
* @evidence contracts/common.md#meaningful-documentation
|
|
2432
|
+
* The native JSDoc explains sorted keys, the acyclic value domain,
|
|
2433
|
+
* unsupported JSON interchange and explicit absence markers. Checked
|
|
2434
|
+
* against the documentation skill: separate paragraphs state the contract
|
|
2435
|
+
* and why its nonobvious boundary matters; field comments retain their own
|
|
2436
|
+
* useful facts.
|
|
1571
2437
|
*/
|
|
1572
2438
|
export function stableStringify(value: unknown): string {
|
|
1573
2439
|
if (Array.isArray(value)) {
|
|
1574
|
-
return `[${
|
|
2440
|
+
return `[${Array.from(value, (item, index) =>
|
|
2441
|
+
Object.prototype.hasOwnProperty.call(value, index)
|
|
2442
|
+
? stableStringify(item)
|
|
2443
|
+
: "hole",
|
|
2444
|
+
).join(",")}]`;
|
|
1575
2445
|
}
|
|
1576
2446
|
if (value !== null && typeof value === "object") {
|
|
1577
2447
|
return `{${Object.entries(value)
|
|
@@ -1579,5 +2449,13 @@ export function stableStringify(value: unknown): string {
|
|
|
1579
2449
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
1580
2450
|
.join(",")}}`;
|
|
1581
2451
|
}
|
|
1582
|
-
|
|
2452
|
+
if (value === undefined) return "undefined";
|
|
2453
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
2454
|
+
throw new TypeError("Cannot fingerprint a non-finite number.");
|
|
2455
|
+
}
|
|
2456
|
+
const encoded = JSON.stringify(value);
|
|
2457
|
+
if (encoded === undefined) {
|
|
2458
|
+
throw new TypeError("Cannot fingerprint an unsupported primitive value.");
|
|
2459
|
+
}
|
|
2460
|
+
return encoded;
|
|
1583
2461
|
}
|