@ttsc/metro 0.30.4 → 0.31.1

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