@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.
Files changed (62) hide show
  1. package/README.md +11 -6
  2. package/lib/core/SnapshotReadOperations.d.ts +119 -0
  3. package/lib/core/SnapshotReadOperations.js +3 -0
  4. package/lib/core/SnapshotReadOperations.js.map +1 -0
  5. package/lib/core/SnapshotReadOperations.mjs +2 -0
  6. package/lib/core/SnapshotReadOperations.mjs.map +1 -0
  7. package/lib/core/TtscMetroOptions.d.ts +119 -0
  8. package/lib/core/TtscMetroOptions.js +3 -0
  9. package/lib/core/TtscMetroOptions.js.map +1 -0
  10. package/lib/core/TtscMetroOptions.mjs +2 -0
  11. package/lib/core/TtscMetroOptions.mjs.map +1 -0
  12. package/lib/core/TtscMetroProjectView.d.ts +64 -0
  13. package/lib/core/TtscMetroProjectView.js +3 -0
  14. package/lib/core/TtscMetroProjectView.js.map +1 -0
  15. package/lib/core/TtscMetroProjectView.mjs +2 -0
  16. package/lib/core/TtscMetroProjectView.mjs.map +1 -0
  17. package/lib/core/UpstreamTransformer.d.ts +127 -0
  18. package/lib/core/UpstreamTransformer.js +3 -0
  19. package/lib/core/UpstreamTransformer.js.map +1 -0
  20. package/lib/core/UpstreamTransformer.mjs +2 -0
  21. package/lib/core/UpstreamTransformer.mjs.map +1 -0
  22. package/lib/core/fingerprint.d.ts +442 -97
  23. package/lib/core/fingerprint.js +838 -83
  24. package/lib/core/fingerprint.js.map +1 -1
  25. package/lib/core/fingerprint.mjs +839 -84
  26. package/lib/core/fingerprint.mjs.map +1 -1
  27. package/lib/core/options.d.ts +81 -58
  28. package/lib/core/options.js +81 -4
  29. package/lib/core/options.js.map +1 -1
  30. package/lib/core/options.mjs +81 -4
  31. package/lib/core/options.mjs.map +1 -1
  32. package/lib/core/remapAstLocations.d.ts +76 -0
  33. package/lib/core/remapAstLocations.js +205 -0
  34. package/lib/core/remapAstLocations.js.map +1 -0
  35. package/lib/core/remapAstLocations.mjs +203 -0
  36. package/lib/core/remapAstLocations.mjs.map +1 -0
  37. package/lib/core/upstream.d.ts +97 -27
  38. package/lib/core/upstream.js +109 -8
  39. package/lib/core/upstream.js.map +1 -1
  40. package/lib/core/upstream.mjs +109 -9
  41. package/lib/core/upstream.mjs.map +1 -1
  42. package/lib/index.d.ts +50 -2
  43. package/lib/index.js +88 -24
  44. package/lib/index.js.map +1 -1
  45. package/lib/index.mjs +89 -25
  46. package/lib/index.mjs.map +1 -1
  47. package/lib/transformer.d.ts +183 -6
  48. package/lib/transformer.js +209 -14
  49. package/lib/transformer.js.map +1 -1
  50. package/lib/transformer.mjs +211 -16
  51. package/lib/transformer.mjs.map +1 -1
  52. package/package.json +7 -18
  53. package/src/core/SnapshotReadOperations.ts +121 -0
  54. package/src/core/TtscMetroOptions.ts +127 -0
  55. package/src/core/TtscMetroProjectView.ts +74 -0
  56. package/src/core/UpstreamTransformer.ts +126 -0
  57. package/src/core/fingerprint.ts +987 -109
  58. package/src/core/options.ts +85 -62
  59. package/src/core/remapAstLocations.ts +219 -0
  60. package/src/core/upstream.ts +110 -26
  61. package/src/index.ts +99 -28
  62. package/src/transformer.ts +214 -17
@@ -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: the compactor renames the merged main into place strictly before
36
- * deleting a worker file, so a worker file that disappears mid-read is always
37
- * already merged into the main the reader loads afterwards.
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 = 2;
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: the base
93
- * directory, plus the resolved tsconfig's directory when the tsconfig is not
94
- * already inside the base walk (an explicit out-of-root `project`, or a
95
- * monorepo-root tsconfig discovered above the app). Matching the transform
96
- * core's own validation universe keeps the invariant simple: everything the
97
- * core treats as an input is fingerprinted by the walk, the recorded snapshot,
98
- * or both.
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 relative = path.relative(resolvedBase, directory);
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
- return inside ? [resolvedBase] : [resolvedBase, directory];
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
- * Exported because the recorder is asked once per input while the answer is a
129
- * property of the project, and validating the memo means stat-ing the whole
130
- * `extends` chain (samchon/ttsc#1316).
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 tsconfig = discovery === undefined
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
- /** Host-platform equality for two resolved path spellings. */
518
+ /** Equality of resolved lexical spellings, preserving distinct native names. */
322
519
  function samePath(left, right) {
323
- return path.relative(path.resolve(left), path.resolve(right)) === "";
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 relative = path.relative(path.resolve(parent), path.resolve(child));
328
- return (relative === "" ||
329
- (relative !== ".." &&
330
- !relative.startsWith(`..${path.sep}`) &&
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
- /** Add one lexical path's stable broad state to a key baseline. */
429
- function addBaselineInput(inputs, file, staticInputs) {
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
- if (existing.identity !== observed.identity ||
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(existing) !== stableStringify(observed))) {
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
- inputs[key] = { ...existing, ...observed };
444
- }
445
- else {
446
- inputs[key] = observed;
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 observed;
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): a concurrent compactor deletes a worker file only after the
520
- // merged main is renamed into place, so whatever this enumeration misses
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 observations = [...recovery.entries, ...workers.entries];
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 worker file stays behind; readers union it, so nothing is
569
- // lost, and the next compaction retries.
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 or any snapshot file is corrupt (a torn or foreign write means the
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.entries) {
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 { files: [...files].sort(), id: main.id, tainted, volatile };
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. One cumulative document is flushed per
797
- * delivered module; the unique name makes worker writes race-free, and
798
- * `withTtsc` compacts the files on the next run.
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
- /** Filesystem-keyed lexical spelling used by main and worker processes. */
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
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
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 compacted (merged into the main snapshot first) and is skipped;
1052
- * a file that exists but does not parse is reported in `corruptPaths` so
1053
- * readers can degrade to a nonce and the compactor can sweep it.
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 = fs.readdirSync(directory);
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 = fs.readFileSync(file, "utf8");
1718
+ text = operations.readFileSync(file, "utf8");
1082
1719
  }
1083
1720
  catch (error) {
1084
- if (!isMissingFileError(error)) {
1085
- corruptPaths.push(file);
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 = fs.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
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 = ["files", "tainted", "version", "volatile"];
1129
- if (Object.prototype.hasOwnProperty.call(document, "id")) {
1130
- expectedKeys.push("id");
1131
- expectedKeys.sort();
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
- new Set(document.files).size !== document.files.length ||
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" || !/^[a-f0-9]{32}$/.test(document.id)))) {
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-serialise with object keys sorted recursively, so two semantically equal
1169
- * records always hash to the same fingerprint regardless of property order.
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 `[${value.map(stableStringify).join(",")}]`;
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
- return JSON.stringify(value);
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;