@ttsc/metro 0.19.0 → 0.19.2

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.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Project fingerprint and reference-graph snapshot for `@ttsc/metro`.
3
+ *
4
+ * Metro's transform cache keys each file on its own content plus one static
5
+ * transformer key computed once per run (`getCacheKey`, called on the main
6
+ * process at `Transformer` construction). A ttsc transform's output can depend
7
+ * on inputs Metro never keys: other project sources reached through type-only
8
+ * edges, `node_modules` declarations, monorepo sibling sources, and the
9
+ * tsconfig `extends` ancestry. This module folds all of them into the static
10
+ * key so the cache key incorporates every input that can influence a
11
+ * transform's output:
12
+ *
13
+ * - **Project walk.** Every input file under the fingerprint roots (Metro's
14
+ * `projectRoot` plus the resolved tsconfig's directory when it lies outside),
15
+ * hashed with the exact walk universe the `@ttsc/unplugin` transform core
16
+ * validates its own cache against.
17
+ * - **Recorded out-of-walk inputs.** The transform core cannot walk files outside
18
+ * the roots or under ignored directories, but the host-owned reference graph
19
+ * (samchon/ttsc#718) reports them per transform. Workers record them into a
20
+ * snapshot under `node_modules/.cache/ttsc-metro`; the next run's
21
+ * `getCacheKey` re-hashes the recorded set.
22
+ *
23
+ * Snapshot layout: one main file carrying a random epoch id plus per-worker
24
+ * files with unique names, so concurrent workers never race a shared write.
25
+ * `withTtsc` (the single config process, before workers exist) compacts worker
26
+ * files into the main file. Readers take the union of every file, reading the
27
+ * worker files strictly before the main file: the compactor renames the merged
28
+ * main into place strictly before deleting a worker file, so a worker file that
29
+ * disappears mid-read is always already merged into the main the reader loads
30
+ * afterwards.
31
+ *
32
+ * Sound degradations, by design:
33
+ *
34
+ * - No readable snapshot (first run, wiped cache dir, unwritable filesystem)
35
+ * folds a random nonce: that run shares no cache entries with any other run.
36
+ * - A recreated snapshot carries a fresh epoch id, so it can never alias a key
37
+ * from an older epoch whose recorded set is unknown.
38
+ * - A plugin-declared volatile output (non-file inputs; unrepresentable in any
39
+ * file fingerprint) marks the snapshot volatile, which also folds a nonce
40
+ * until a later run records the volatile declaration gone.
41
+ * - A recorded input that disappears hashes as a stable `missing` marker, so
42
+ * deletion and reappearance both move the key.
43
+ */
44
+ import {
45
+ collectExternalInputHashes,
46
+ collectProjectInputHashes,
47
+ isProjectWalkPath,
48
+ } from "@ttsc/unplugin/api";
49
+ import { createHash, randomBytes } from "node:crypto";
50
+ import fs from "node:fs";
51
+ import path from "node:path";
52
+
53
+ /** Bumped when the snapshot JSON shape changes; mismatches read as corrupt. */
54
+ const SNAPSHOT_VERSION = 1;
55
+
56
+ /** Snapshot directory segments under the fingerprint base directory. */
57
+ const SNAPSHOT_DIRECTORY = ["node_modules", ".cache", "ttsc-metro"];
58
+
59
+ /** Main snapshot file name (epoch id + compacted recorded inputs). */
60
+ const MAIN_SNAPSHOT = "graph-inputs.json";
61
+
62
+ /** Worker snapshot file prefix; each worker appends a unique suffix. */
63
+ const WORKER_SNAPSHOT_PREFIX = "graph-inputs.worker-";
64
+
65
+ /** Union of the snapshot state readable on disk. */
66
+ interface SnapshotState {
67
+ /** Random epoch id minted when the main snapshot was created. */
68
+ id: string;
69
+ /** Absolute paths of every recorded out-of-walk input. */
70
+ files: string[];
71
+ /** Whether any recorded transform declared volatile output. */
72
+ volatile: boolean;
73
+ }
74
+
75
+ /** Serialized shape of the main and worker snapshot files. */
76
+ interface SnapshotDocument {
77
+ files: string[];
78
+ id?: string;
79
+ version: number;
80
+ volatile: boolean;
81
+ }
82
+
83
+ /**
84
+ * Resolve the base directory both fingerprint sides agree on: Metro's
85
+ * `projectRoot` when known (`withTtsc` reads it from the config, `getCacheKey`
86
+ * from Metro's cache-key options, the transformer from each file's transform
87
+ * options — all the same value in a real Metro run), else the working directory
88
+ * Metro was launched from.
89
+ */
90
+ export function resolveFingerprintBase(
91
+ projectRoot: string | undefined,
92
+ ): string {
93
+ return path.resolve(
94
+ typeof projectRoot === "string" && projectRoot.length !== 0
95
+ ? projectRoot
96
+ : process.cwd(),
97
+ );
98
+ }
99
+
100
+ /**
101
+ * The directories whose walk universes the fingerprint hashes: the base
102
+ * directory, plus the resolved tsconfig's directory when the tsconfig is not
103
+ * already inside the base walk (an explicit out-of-root `project`, or a
104
+ * monorepo-root tsconfig discovered above the app). Matching the transform
105
+ * core's own validation universe keeps the invariant simple: everything the
106
+ * core treats as an input is fingerprinted, either by a walk here or by the
107
+ * recorded out-of-walk snapshot.
108
+ */
109
+ export function fingerprintRoots(
110
+ base: string,
111
+ explicitProject: string | undefined,
112
+ ): string[] {
113
+ const tsconfig = resolveProjectTsconfig(base, explicitProject);
114
+ if (isProjectWalkPath(base, tsconfig)) {
115
+ return [base];
116
+ }
117
+ return [base, path.dirname(tsconfig)];
118
+ }
119
+
120
+ /**
121
+ * Locate the tsconfig governing the project, mirroring the transform core's
122
+ * discovery: an explicit `project` resolves against the working directory;
123
+ * otherwise ancestor directories starting at `base` are searched for a
124
+ * `tsconfig.json`, falling back to `<base>/tsconfig.json`.
125
+ */
126
+ function resolveProjectTsconfig(
127
+ base: string,
128
+ explicitProject: string | undefined,
129
+ ): string {
130
+ if (explicitProject !== undefined && explicitProject.length !== 0) {
131
+ return path.isAbsolute(explicitProject)
132
+ ? explicitProject
133
+ : path.resolve(process.cwd(), explicitProject);
134
+ }
135
+ let current = base;
136
+ while (true) {
137
+ const candidate = path.join(current, "tsconfig.json");
138
+ if (fs.existsSync(candidate)) {
139
+ return candidate;
140
+ }
141
+ const parent = path.dirname(current);
142
+ if (parent === current) {
143
+ break;
144
+ }
145
+ current = parent;
146
+ }
147
+ return path.resolve(base, "tsconfig.json");
148
+ }
149
+
150
+ /**
151
+ * Compute the fingerprint `getCacheKey` folds into Metro's static transformer
152
+ * key. Never throws: any failure degrades to a nonce, which soundly disables
153
+ * cross-run cache reuse for this run instead of serving stale output.
154
+ */
155
+ export function computeProjectFingerprint(props: {
156
+ explicitProject?: string;
157
+ projectRoot?: string;
158
+ }): string {
159
+ try {
160
+ const base = resolveFingerprintBase(props.projectRoot);
161
+ const hash = createHash("sha256");
162
+ for (const root of fingerprintRoots(base, props.explicitProject)) {
163
+ hash.update(stableStringify(collectProjectInputHashes(root)));
164
+ }
165
+ const snapshot = readSnapshotState(base);
166
+ if (snapshot === undefined || snapshot.volatile) {
167
+ hash.update(nonce());
168
+ } else {
169
+ hash.update(`snapshot:${snapshot.id}`);
170
+ hash.update(stableStringify(collectExternalInputHashes(snapshot.files)));
171
+ }
172
+ return hash.digest("hex");
173
+ } catch {
174
+ return nonce();
175
+ }
176
+ }
177
+
178
+ /**
179
+ * A value no other run can reproduce. Folding it means this run's cache entries
180
+ * are written but never reused by later runs, and this run reuses nothing from
181
+ * earlier ones — the sound fallback whenever the recorded out-of-walk input set
182
+ * is unknown or unrepresentable.
183
+ */
184
+ function nonce(): string {
185
+ return `nonce:${randomBytes(32).toString("hex")}`;
186
+ }
187
+
188
+ /**
189
+ * Prepare the snapshot for a new run. Called from `withTtsc` in the single
190
+ * Metro config process, before any worker exists: creates the main snapshot
191
+ * (fresh epoch id) when missing or corrupt, compacts leftover worker files into
192
+ * it, and sweeps unparseable worker files plus crash-leftover temp files. An
193
+ * unparseable worker file's recordings are unrecoverable, so its removal mints
194
+ * a fresh epoch id — every key that might have depended on the lost recordings
195
+ * is soundly orphaned, and later runs stabilize instead of degrading to a nonce
196
+ * forever. Never throws — an unwritable cache directory leaves the snapshot
197
+ * unreadable and `getCacheKey` degrades to a nonce.
198
+ */
199
+ export function prepareSnapshot(projectRoot: string | undefined): void {
200
+ try {
201
+ const base = resolveFingerprintBase(projectRoot);
202
+ // A nonexistent base can never be a working Metro setup (Metro verifies
203
+ // the project root exists), so preparing a snapshot there would only
204
+ // materialize directory trees at arbitrary paths.
205
+ if (!fs.existsSync(base)) {
206
+ return;
207
+ }
208
+ const directory = snapshotDirectory(base);
209
+ fs.mkdirSync(directory, { recursive: true });
210
+ // Read the worker files strictly before the main file (see the module doc
211
+ // comment): a concurrent compactor deletes a worker file only after the
212
+ // merged main is renamed into place, so whatever this enumeration misses
213
+ // is already inside the main read below.
214
+ const workers = readWorkerFiles(directory);
215
+ const main = readMainDocument(directory);
216
+ const files = new Set(main?.files ?? []);
217
+ const volatile =
218
+ workers.entries.length === 0
219
+ ? (main?.volatile ?? false)
220
+ : // Worker files carry the previous run's fresh observations, so they
221
+ // own the volatile verdict: a removed volatile declaration must be
222
+ // able to clear the sticky flag.
223
+ workers.entries.some((entry) => entry.volatile);
224
+ for (const entry of workers.entries) {
225
+ for (const file of entry.files) {
226
+ files.add(file);
227
+ }
228
+ }
229
+ writeSnapshotDocument(path.join(directory, MAIN_SNAPSHOT), {
230
+ files: [...files].sort(),
231
+ id:
232
+ workers.corruptPaths.length === 0
233
+ ? (main?.id ?? randomBytes(16).toString("hex"))
234
+ : randomBytes(16).toString("hex"),
235
+ version: SNAPSHOT_VERSION,
236
+ volatile,
237
+ });
238
+ for (const file of [
239
+ ...workers.paths,
240
+ ...workers.corruptPaths,
241
+ ...listTemporaryFiles(directory),
242
+ ]) {
243
+ try {
244
+ fs.rmSync(file, { force: true });
245
+ } catch {
246
+ // A locked worker file stays behind; readers union it, so nothing is
247
+ // lost, and the next compaction retries.
248
+ }
249
+ }
250
+ } catch {
251
+ // Snapshot maintenance must never break the Metro config process.
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Crash-leftover temp files from the atomic writer, swept at compaction. Only
257
+ * files older than a day qualify: a young temp file may belong to a live writer
258
+ * in a concurrently running Metro instance, and deleting it mid-write would
259
+ * silently drop that writer's recordings.
260
+ */
261
+ function listTemporaryFiles(directory: string): string[] {
262
+ const horizon = Date.now() - 24 * 60 * 60 * 1000;
263
+ try {
264
+ return fs
265
+ .readdirSync(directory)
266
+ .filter((name) => name.endsWith(".tmp"))
267
+ .map((name) => path.join(directory, name))
268
+ .filter((file) => {
269
+ try {
270
+ return fs.statSync(file).mtimeMs < horizon;
271
+ } catch {
272
+ return false;
273
+ }
274
+ });
275
+ } catch {
276
+ return [];
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Read the unioned snapshot state, or `undefined` when the main snapshot is
282
+ * missing or any snapshot file is corrupt (a torn or foreign write means the
283
+ * recorded set cannot be trusted, so the caller degrades to a nonce).
284
+ */
285
+ export function readSnapshotState(base: string): SnapshotState | undefined {
286
+ const directory = snapshotDirectory(base);
287
+ // Worker files strictly before the main file — see the module doc comment.
288
+ const workers = readWorkerFiles(directory);
289
+ if (workers.corruptPaths.length !== 0) {
290
+ return undefined;
291
+ }
292
+ const main = readMainDocument(directory);
293
+ if (main === undefined || typeof main.id !== "string") {
294
+ return undefined;
295
+ }
296
+ const files = new Set(main.files);
297
+ let volatile = main.volatile;
298
+ for (const entry of workers.entries) {
299
+ for (const file of entry.files) {
300
+ files.add(file);
301
+ }
302
+ volatile ||= entry.volatile;
303
+ }
304
+ return { files: [...files].sort(), id: main.id, volatile };
305
+ }
306
+
307
+ /**
308
+ * Recorder held by each Metro worker. Collects the out-of-walk inputs derived
309
+ * for every transformed file (the plugin-reported dependencies unioned with the
310
+ * reference graph's reach, globals, and configs — delivered through the
311
+ * transform core's `addWatchFile` hook) plus any volatile declaration, and
312
+ * persists them to this worker's uniquely named snapshot file whenever the
313
+ * observed state grows. The unique name makes worker writes race-free;
314
+ * `withTtsc` compacts the files on the next run.
315
+ */
316
+ export function createSnapshotRecorder(): {
317
+ record: (props: {
318
+ explicitProject?: string;
319
+ input: string;
320
+ projectRoot?: string;
321
+ }) => void;
322
+ recordVolatile: (props: {
323
+ explicitProject?: string;
324
+ projectRoot?: string;
325
+ }) => void;
326
+ } {
327
+ const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
328
+ interface BaseState {
329
+ dirty: boolean;
330
+ files: Set<string>;
331
+ roots: string[];
332
+ volatile: boolean;
333
+ }
334
+ const states = new Map<string, BaseState>();
335
+
336
+ function stateFor(
337
+ projectRoot: string | undefined,
338
+ explicitProject: string | undefined,
339
+ ): BaseState {
340
+ const base = resolveFingerprintBase(projectRoot);
341
+ let state = states.get(base);
342
+ if (state === undefined) {
343
+ state = {
344
+ dirty: false,
345
+ files: new Set(),
346
+ roots: fingerprintRoots(base, explicitProject),
347
+ volatile: false,
348
+ };
349
+ states.set(base, state);
350
+ }
351
+ return state;
352
+ }
353
+
354
+ function flush(base: string, state: BaseState): void {
355
+ if (!state.dirty) {
356
+ return;
357
+ }
358
+ try {
359
+ const directory = snapshotDirectory(base);
360
+ fs.mkdirSync(directory, { recursive: true });
361
+ writeSnapshotDocument(
362
+ path.join(directory, `${WORKER_SNAPSHOT_PREFIX}${suffix}.json`),
363
+ {
364
+ files: [...state.files].sort(),
365
+ version: SNAPSHOT_VERSION,
366
+ volatile: state.volatile,
367
+ },
368
+ );
369
+ // Cleared only on success so a transient write failure retries on the
370
+ // next recording instead of silently dropping the observed state.
371
+ state.dirty = false;
372
+ } catch {
373
+ // An unwritable snapshot leaves the main snapshot unreadable or stale;
374
+ // getCacheKey degrades to a nonce rather than serving stale output.
375
+ }
376
+ }
377
+
378
+ return {
379
+ record(props) {
380
+ const base = resolveFingerprintBase(props.projectRoot);
381
+ const state = stateFor(props.projectRoot, props.explicitProject);
382
+ const input = path.resolve(props.input);
383
+ if (
384
+ state.files.has(input) ||
385
+ state.roots.some((root) => isProjectWalkPath(root, input))
386
+ ) {
387
+ return;
388
+ }
389
+ state.files.add(input);
390
+ state.dirty = true;
391
+ flush(base, state);
392
+ },
393
+ recordVolatile(props) {
394
+ const base = resolveFingerprintBase(props.projectRoot);
395
+ const state = stateFor(props.projectRoot, props.explicitProject);
396
+ if (state.volatile) {
397
+ return;
398
+ }
399
+ state.volatile = true;
400
+ state.dirty = true;
401
+ flush(base, state);
402
+ },
403
+ };
404
+ }
405
+
406
+ function snapshotDirectory(base: string): string {
407
+ return path.join(base, ...SNAPSHOT_DIRECTORY);
408
+ }
409
+
410
+ /**
411
+ * Read every worker snapshot file in `directory`. A file that disappears
412
+ * mid-read was compacted (merged into the main snapshot first) and is skipped;
413
+ * a file that exists but does not parse is reported in `corruptPaths` so
414
+ * readers can degrade to a nonce and the compactor can sweep it.
415
+ */
416
+ function readWorkerFiles(directory: string): {
417
+ corruptPaths: string[];
418
+ entries: SnapshotDocument[];
419
+ paths: string[];
420
+ } {
421
+ let names: string[];
422
+ try {
423
+ names = fs.readdirSync(directory);
424
+ } catch {
425
+ return { corruptPaths: [], entries: [], paths: [] };
426
+ }
427
+ const entries: SnapshotDocument[] = [];
428
+ const paths: string[] = [];
429
+ const corruptPaths: string[] = [];
430
+ for (const name of names) {
431
+ if (!name.startsWith(WORKER_SNAPSHOT_PREFIX) || !name.endsWith(".json")) {
432
+ continue;
433
+ }
434
+ const file = path.join(directory, name);
435
+ let text: string;
436
+ try {
437
+ text = fs.readFileSync(file, "utf8");
438
+ } catch {
439
+ continue;
440
+ }
441
+ const parsed = parseSnapshotDocument(text);
442
+ if (parsed === undefined) {
443
+ corruptPaths.push(file);
444
+ continue;
445
+ }
446
+ entries.push(parsed);
447
+ paths.push(file);
448
+ }
449
+ return { corruptPaths, entries, paths };
450
+ }
451
+
452
+ function readMainDocument(directory: string): SnapshotDocument | undefined {
453
+ let text: string;
454
+ try {
455
+ text = fs.readFileSync(path.join(directory, MAIN_SNAPSHOT), "utf8");
456
+ } catch {
457
+ return undefined;
458
+ }
459
+ return parseSnapshotDocument(text);
460
+ }
461
+
462
+ function parseSnapshotDocument(text: string): SnapshotDocument | undefined {
463
+ let value: unknown;
464
+ try {
465
+ value = JSON.parse(text);
466
+ } catch {
467
+ return undefined;
468
+ }
469
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
470
+ return undefined;
471
+ }
472
+ const document = value as Record<string, unknown>;
473
+ if (document.version !== SNAPSHOT_VERSION || !Array.isArray(document.files)) {
474
+ return undefined;
475
+ }
476
+ return {
477
+ files: document.files.filter(
478
+ (entry): entry is string => typeof entry === "string",
479
+ ),
480
+ ...(typeof document.id === "string" ? { id: document.id } : {}),
481
+ version: SNAPSHOT_VERSION,
482
+ volatile: document.volatile === true,
483
+ };
484
+ }
485
+
486
+ /** Write a snapshot document atomically (unique temp file, then rename). */
487
+ function writeSnapshotDocument(file: string, document: SnapshotDocument): void {
488
+ const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
489
+ fs.writeFileSync(temp, JSON.stringify(document), "utf8");
490
+ try {
491
+ fs.renameSync(temp, file);
492
+ } catch (error) {
493
+ fs.rmSync(temp, { force: true });
494
+ throw error;
495
+ }
496
+ }
497
+
498
+ /**
499
+ * JSON-serialise with object keys sorted recursively, so two semantically equal
500
+ * records always hash to the same fingerprint regardless of property order.
501
+ * Shared with the transformer's option digest.
502
+ */
503
+ export function stableStringify(value: unknown): string {
504
+ if (Array.isArray(value)) {
505
+ return `[${value.map(stableStringify).join(",")}]`;
506
+ }
507
+ if (value !== null && typeof value === "object") {
508
+ return `{${Object.entries(value)
509
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
510
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
511
+ .join(",")}}`;
512
+ }
513
+ return JSON.stringify(value);
514
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@
30
30
  import { dirname, join } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
32
 
33
+ import { prepareSnapshot } from "./core/fingerprint";
33
34
  import type { TtscMetroOptions } from "./core/options";
34
35
  import { ENV_KEY, serializeOptions } from "./core/options";
35
36
 
@@ -69,6 +70,13 @@ export function withTtsc<T extends MetroConfigLike>(
69
70
  options: TtscMetroOptions = {},
70
71
  ): T {
71
72
  process.env[ENV_KEY] = serializeOptions(options);
73
+ // Prepare the reference-graph snapshot backing the transformer's cache-key
74
+ // fingerprint (see `core/fingerprint.ts`). This runs in the single Metro
75
+ // config process before any worker exists, so it is the race-free moment to
76
+ // mint the snapshot epoch and compact the previous run's worker files.
77
+ prepareSnapshot(
78
+ typeof config.projectRoot === "string" ? config.projectRoot : undefined,
79
+ );
72
80
  return {
73
81
  ...config,
74
82
  transformer: {
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * The ttsc pass reuses `@ttsc/unplugin`'s `transformTtsc`, so the plugin
12
12
  * contract, tsconfig discovery, and per-build cache are identical to every
13
- * other bundler integration. See the package README for the v1 cost model and
14
- * the cross-file cache-invalidation caveat.
13
+ * other bundler integration. Cross-file cache invalidation rides the project
14
+ * fingerprint {@link getCacheKey} folds into Metro's static transformer key (see
15
+ * `core/fingerprint.ts`); the package README covers the v1 cost model and the
16
+ * remaining watch-session boundary.
15
17
  */
16
18
  import {
17
19
  createTtscTransformCache,
@@ -22,6 +24,11 @@ import { createHash } from "node:crypto";
22
24
  import { createRequire } from "node:module";
23
25
  import path from "node:path";
24
26
 
27
+ import {
28
+ computeProjectFingerprint,
29
+ createSnapshotRecorder,
30
+ stableStringify,
31
+ } from "./core/fingerprint";
25
32
  import type { ResolvedTtscMetroOptions } from "./core/options";
26
33
  import { resolveOptionsFromEnv } from "./core/options";
27
34
  import { resolveUpstreamTransformer } from "./core/upstream";
@@ -45,6 +52,7 @@ const DECLARATION = /\.d\.[cm]?ts$/;
45
52
  let resolved: ResolvedTtscMetroOptions | undefined;
46
53
  let unpluginOptions: ReturnType<typeof resolveOptions> | undefined;
47
54
  const cache = createTtscTransformCache();
55
+ const snapshotRecorder = createSnapshotRecorder();
48
56
 
49
57
  /** Lazily resolve the worker-side options (from {@link resolveOptionsFromEnv}). */
50
58
  function options(): ResolvedTtscMetroOptions {
@@ -106,12 +114,33 @@ export async function transform(params: {
106
114
  let transformedSrc = params.src;
107
115
  try {
108
116
  unpluginOptions ??= resolveOptions(opts.ttsc);
117
+ const projectRoot =
118
+ typeof params.options.projectRoot === "string"
119
+ ? params.options.projectRoot
120
+ : undefined;
121
+ const explicitProject =
122
+ typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined;
109
123
  const result = await transformTtsc(
110
124
  resolveAbsoluteFilename(params.filename, params.options),
111
125
  params.src,
112
126
  unpluginOptions,
113
127
  undefined,
114
128
  cache,
129
+ {
130
+ // Metro offers no per-file dependency registration, so the derived
131
+ // watch inputs (plugin-reported dependencies unioned with the
132
+ // reference graph's reach, globals, and configs) feed the snapshot
133
+ // that the next run's getCacheKey re-hashes instead. Fires on cache
134
+ // hits too, so a worker that never recompiled still records the
135
+ // inputs backing the outputs it serves.
136
+ addWatchFile: (input) =>
137
+ snapshotRecorder.record({ explicitProject, input, projectRoot }),
138
+ // A volatile declaration means the output depends on non-file inputs
139
+ // that no file fingerprint can represent; the snapshot marks it and
140
+ // getCacheKey degrades to a per-run nonce (no cross-run reuse).
141
+ markVolatile: () =>
142
+ snapshotRecorder.recordVolatile({ explicitProject, projectRoot }),
143
+ },
115
144
  );
116
145
  if (result !== undefined && typeof result.code === "string") {
117
146
  transformedSrc = result.code;
@@ -131,15 +160,25 @@ export async function transform(params: {
131
160
  /**
132
161
  * Metro transform-cache key.
133
162
  *
134
- * Metro already content-hashes each file, so this only has to invalidate when
135
- * the transformer itself changes: package version + resolved options + the
136
- * upstream transformer's own key (forwarded Metro's args, e.g. `projectRoot`,
137
- * so a `babel.config.js` change still busts the cache). Resolving the upstream
138
- * is deliberately non-fatal here: a missing peer must not crash cache-key
139
- * computation. NOTE: this does not encode the tsconfig / plugin configuration
140
- * or cross-file type dependencies, so after editing those (or a depended-upon
141
- * type) run Metro with `--reset-cache`. See the README "Caveats" and
142
- * samchon/ttsc#255.
163
+ * Metro calls this once per run (dev-server start or cold `metro bundle`), on
164
+ * the main process, and folds the result into every file's per-content cache
165
+ * key. It must therefore incorporate every input that can influence a
166
+ * transform's output beyond the file's own content:
167
+ *
168
+ * - The transformer identity: package version + resolved options + the upstream
169
+ * transformer's own key (forwarded Metro's args, e.g. `projectRoot`, so a
170
+ * `babel.config.js` change still busts the cache);
171
+ * - The project fingerprint (see `core/fingerprint.ts`): every input file under
172
+ * the project walk (tsconfig, plugin configs, type-only siblings) plus the
173
+ * recorded out-of-walk reference-graph members from previous transforms
174
+ * (`node_modules` declarations, monorepo sibling sources, out-of-root config
175
+ * ancestry).
176
+ *
177
+ * A change to any fingerprinted input re-keys every transformed file —
178
+ * project-level granularity, forced by Metro's single static key — replacing
179
+ * the former manual `--reset-cache` step. Resolving the upstream is
180
+ * deliberately non-fatal here: a missing peer must not crash cache-key
181
+ * computation. See the README "Caveats" and samchon/ttsc#721.
143
182
  */
144
183
  export function getCacheKey(...args: unknown[]): string {
145
184
  const opts = options();
@@ -157,9 +196,34 @@ export function getCacheKey(...args: unknown[]): string {
157
196
  if (upstreamKey.length !== 0) {
158
197
  hash.update(upstreamKey);
159
198
  }
199
+ hash.update(
200
+ computeProjectFingerprint({
201
+ explicitProject:
202
+ typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined,
203
+ projectRoot: cacheKeyProjectRoot(args),
204
+ }),
205
+ );
160
206
  return hash.digest("hex");
161
207
  }
162
208
 
209
+ /**
210
+ * Extract Metro's `projectRoot` from the cache-key options
211
+ * (`metro-transform-worker` calls `getCacheKey({ projectRoot,
212
+ * enableBabelRCLookup })`). Defensive against foreign callers: anything but a
213
+ * non-empty string yields `undefined` and the fingerprint falls back to the
214
+ * working directory.
215
+ */
216
+ function cacheKeyProjectRoot(args: unknown[]): string | undefined {
217
+ const first = args[0];
218
+ if (typeof first !== "object" || first === null) {
219
+ return undefined;
220
+ }
221
+ const projectRoot = (first as Record<string, unknown>).projectRoot;
222
+ return typeof projectRoot === "string" && projectRoot.length !== 0
223
+ ? projectRoot
224
+ : undefined;
225
+ }
226
+
163
227
  /**
164
228
  * Fold the upstream transformer's cache key in, defensively. Forwards Metro's
165
229
  * own `getCacheKey` arguments so the upstream's babelrc-derived key is
@@ -230,20 +294,3 @@ function packageVersion(): string {
230
294
  return "0";
231
295
  }
232
296
  }
233
-
234
- /**
235
- * JSON-serialise with object keys sorted recursively, so two semantically equal
236
- * option sets always hash to the same cache key regardless of property order.
237
- */
238
- function stableStringify(value: unknown): string {
239
- if (Array.isArray(value)) {
240
- return `[${value.map(stableStringify).join(",")}]`;
241
- }
242
- if (value !== null && typeof value === "object") {
243
- return `{${Object.entries(value)
244
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
245
- .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
246
- .join(",")}}`;
247
- }
248
- return JSON.stringify(value);
249
- }