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