@ttsc/metro 0.30.4 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +11 -6
  2. package/lib/core/SnapshotReadOperations.d.ts +119 -0
  3. package/lib/core/SnapshotReadOperations.js +3 -0
  4. package/lib/core/SnapshotReadOperations.js.map +1 -0
  5. package/lib/core/SnapshotReadOperations.mjs +2 -0
  6. package/lib/core/SnapshotReadOperations.mjs.map +1 -0
  7. package/lib/core/TtscMetroOptions.d.ts +119 -0
  8. package/lib/core/TtscMetroOptions.js +3 -0
  9. package/lib/core/TtscMetroOptions.js.map +1 -0
  10. package/lib/core/TtscMetroOptions.mjs +2 -0
  11. package/lib/core/TtscMetroOptions.mjs.map +1 -0
  12. package/lib/core/TtscMetroProjectView.d.ts +64 -0
  13. package/lib/core/TtscMetroProjectView.js +3 -0
  14. package/lib/core/TtscMetroProjectView.js.map +1 -0
  15. package/lib/core/TtscMetroProjectView.mjs +2 -0
  16. package/lib/core/TtscMetroProjectView.mjs.map +1 -0
  17. package/lib/core/UpstreamTransformer.d.ts +127 -0
  18. package/lib/core/UpstreamTransformer.js +3 -0
  19. package/lib/core/UpstreamTransformer.js.map +1 -0
  20. package/lib/core/UpstreamTransformer.mjs +2 -0
  21. package/lib/core/UpstreamTransformer.mjs.map +1 -0
  22. package/lib/core/fingerprint.d.ts +442 -97
  23. package/lib/core/fingerprint.js +838 -83
  24. package/lib/core/fingerprint.js.map +1 -1
  25. package/lib/core/fingerprint.mjs +839 -84
  26. package/lib/core/fingerprint.mjs.map +1 -1
  27. package/lib/core/options.d.ts +81 -58
  28. package/lib/core/options.js +81 -4
  29. package/lib/core/options.js.map +1 -1
  30. package/lib/core/options.mjs +81 -4
  31. package/lib/core/options.mjs.map +1 -1
  32. package/lib/core/remapAstLocations.d.ts +76 -0
  33. package/lib/core/remapAstLocations.js +205 -0
  34. package/lib/core/remapAstLocations.js.map +1 -0
  35. package/lib/core/remapAstLocations.mjs +203 -0
  36. package/lib/core/remapAstLocations.mjs.map +1 -0
  37. package/lib/core/upstream.d.ts +97 -27
  38. package/lib/core/upstream.js +109 -8
  39. package/lib/core/upstream.js.map +1 -1
  40. package/lib/core/upstream.mjs +109 -9
  41. package/lib/core/upstream.mjs.map +1 -1
  42. package/lib/index.d.ts +50 -2
  43. package/lib/index.js +88 -24
  44. package/lib/index.js.map +1 -1
  45. package/lib/index.mjs +89 -25
  46. package/lib/index.mjs.map +1 -1
  47. package/lib/transformer.d.ts +183 -6
  48. package/lib/transformer.js +209 -14
  49. package/lib/transformer.js.map +1 -1
  50. package/lib/transformer.mjs +211 -16
  51. package/lib/transformer.mjs.map +1 -1
  52. package/package.json +7 -18
  53. package/src/core/SnapshotReadOperations.ts +121 -0
  54. package/src/core/TtscMetroOptions.ts +127 -0
  55. package/src/core/TtscMetroProjectView.ts +74 -0
  56. package/src/core/UpstreamTransformer.ts +126 -0
  57. package/src/core/fingerprint.ts +987 -109
  58. package/src/core/options.ts +85 -62
  59. package/src/core/remapAstLocations.ts +219 -0
  60. package/src/core/upstream.ts +110 -26
  61. package/src/index.ts +99 -28
  62. package/src/transformer.ts +214 -17
package/src/index.ts CHANGED
@@ -27,19 +27,22 @@
27
27
  * module.exports = withTtsc(getDefaultConfig(__dirname));
28
28
  * ```
29
29
  */
30
- import { readFileSync, realpathSync } from "node:fs";
30
+ import { openTtscTransformSession } from "@ttsc/unplugin/api";
31
+ import { readFileSync } from "node:fs";
31
32
  import { createRequire } from "node:module";
32
33
  import { basename, dirname, join, resolve } from "node:path";
33
34
  import { fileURLToPath } from "node:url";
35
+ import { createFilesystemPathIdentityContext } from "ttsc/path-identity";
34
36
 
37
+ import type { TtscMetroOptions } from "./core/TtscMetroOptions";
35
38
  import { prepareSnapshot } from "./core/fingerprint";
36
- import type { TtscMetroOptions } from "./core/options";
37
39
  import { ENV_KEY, serializeOptions } from "./core/options";
40
+ import { locateProjectUpstreamTransformer } from "./core/upstream";
38
41
 
39
42
  export type {
40
43
  ResolvedTtscMetroOptions,
41
44
  TtscMetroOptions,
42
- } from "./core/options";
45
+ } from "./core/TtscMetroOptions";
43
46
 
44
47
  /**
45
48
  * Minimal structural type for a Metro config object, avoids a hard dependency
@@ -74,6 +77,54 @@ interface MetroConfigLike {
74
77
  * `.svg` to the auto-detected Expo default instead, with the build still
75
78
  * succeeding (samchon/ttsc#1321). An explicit `upstreamTransformer` option
76
79
  * still wins, since that is the caller saying it outright.
80
+ *
81
+ * The call also has side effects beyond the returned config, all before Metro
82
+ * forks its workers. It prepares the cache-key snapshot under
83
+ * `<projectRoot>/node_modules/.cache/ttsc-metro` (creating it, merging the
84
+ * previous workers' records into it under a lock, and sweeping stale files),
85
+ * and it opens the shared compile session whose path the workers inherit
86
+ * through the environment. A snapshot that cannot be prepared never fails the
87
+ * call: the run takes a private token that makes its cache key non-reusable.
88
+ *
89
+ * @evidence contracts/common.md#principled-implementation
90
+ * Metro's supported babelTransformerPath boundary installs this adapter by
91
+ * cloning the config. Node project resolution preserves an existing upstream
92
+ * unless explicitly overridden, and realpath/package ownership filters
93
+ * inherited self-selection; explicit upstream choices remain caller-owned.
94
+ * Only the owned option/session environment channels are published before
95
+ * workers start; foreign loaders and methods are not patched.
96
+ *
97
+ * @evidence contracts/common.md#clear-and-simple-design
98
+ * The entry point prepares run ownership and worker transport, then clones
99
+ * the config's transformer field. Separate helpers own upstream inheritance
100
+ * and module identity instead of repeating those policies in workers.
101
+ *
102
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
103
+ * Decision values come from the documented inputs and product protocol
104
+ * rather than expected test answers. No compensating path is introduced to
105
+ * make a known example pass.
106
+ *
107
+ * @evidence contracts/portability.md#os-neutral-implementation
108
+ * Node paths, createRequire and fileURLToPath resolve native paths and module
109
+ * URLs. The shared filesystem-identity resolver handles links and actual
110
+ * directory case capabilities in the recursion guard, instead of folding
111
+ * all names by OS. Worker options use JSON environment inheritance.
112
+ *
113
+ * @evidence contracts/common.md#meaningful-documentation
114
+ * The native JSDoc explains config preservation, upstream precedence, JSON
115
+ * worker transport and preparation side effects. Checked against the
116
+ * documentation skill: separate paragraphs state the contract and why its
117
+ * nonobvious boundary matters; field comments retain their own useful
118
+ * facts.
119
+ *
120
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
121
+ * The compaction lock it takes is released in prepareSnapshot's finally block and the session store is owned by its opener; withTtsc retains no handle itself.
122
+ *
123
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
124
+ * Constant work per call besides the snapshot preparation that prepareSnapshot owns.
125
+ *
126
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
127
+ * Runs once per Metro config load; the snapshot and compile session it opens are what the workers share.
77
128
  */
78
129
  export function withTtsc<T extends MetroConfigLike>(
79
130
  config: T,
@@ -81,8 +132,8 @@ export function withTtsc<T extends MetroConfigLike>(
81
132
  ): T {
82
133
  // Prepare the reference-graph snapshot backing the transformer's cache-key
83
134
  // fingerprint (see `core/fingerprint.ts`). This runs in the single Metro
84
- // config process before any worker exists, so it is the race-free moment to
85
- // mint the snapshot epoch and compact the previous run's worker files.
135
+ // config process before its workers exist. The snapshot owner serializes
136
+ // concurrent config processes while compacting the previous worker records.
86
137
  const snapshotRunId = prepareSnapshot(
87
138
  typeof config.projectRoot === "string" ? config.projectRoot : undefined,
88
139
  );
@@ -90,6 +141,9 @@ export function withTtsc<T extends MetroConfigLike>(
90
141
  inheritConfiguredTransformer(config, options),
91
142
  snapshotRunId,
92
143
  );
144
+ // The workers Metro forks next inherit the session, so the pool compiles
145
+ // each generation once between them (samchon/ttsc#1390).
146
+ openTtscTransformSession();
93
147
  return {
94
148
  ...config,
95
149
  transformer: {
@@ -116,13 +170,31 @@ function inheritConfiguredTransformer(
116
170
  config: MetroConfigLike,
117
171
  options: TtscMetroOptions,
118
172
  ): TtscMetroOptions {
119
- const declared = config.transformer?.babelTransformerPath;
173
+ // An explicit option is the caller's module, so it resolves from the project
174
+ // as a declared `babelTransformerPath` does; an empty one asks for detection.
120
175
  if (
121
- options.upstreamTransformer !== undefined ||
122
- typeof declared !== "string" ||
123
- declared.length === 0
176
+ options.upstreamTransformer !== undefined &&
177
+ options.upstreamTransformer.length !== 0
124
178
  ) {
125
- return options;
179
+ return {
180
+ ...options,
181
+ upstreamTransformer: resolveFromProject(
182
+ options.upstreamTransformer,
183
+ config,
184
+ ),
185
+ };
186
+ }
187
+ const declared = config.transformer?.babelTransformerPath;
188
+ if (typeof declared !== "string" || declared.length === 0) {
189
+ // Automatic detection belongs here too: the candidates are the app's
190
+ // packages, and only this process knows the project to look in. Nothing
191
+ // found leaves the worker's own probe to report it.
192
+ const located = locateProjectUpstreamTransformer((specifier) =>
193
+ projectRequire(config).resolve(specifier),
194
+ );
195
+ return located === undefined
196
+ ? options
197
+ : { ...options, upstreamTransformer: located };
126
198
  }
127
199
  // Resolve before judging. Ownership is a property of the module, not of the
128
200
  // string, and every spelling has to become one absolute path before either
@@ -161,17 +233,22 @@ function inheritConfiguredTransformer(
161
233
  * somewhere less legible.
162
234
  */
163
235
  function resolveFromProject(declared: string, config: MetroConfigLike): string {
164
- const base =
165
- typeof config.projectRoot === "string" && config.projectRoot.length !== 0
166
- ? config.projectRoot
167
- : process.cwd();
168
236
  try {
169
- return createRequire(join(resolve(base), "package.json")).resolve(declared);
237
+ return projectRequire(config).resolve(declared);
170
238
  } catch {
171
239
  return declared;
172
240
  }
173
241
  }
174
242
 
243
+ /** A `require` rooted in the Metro project, the owner of its transformers. */
244
+ function projectRequire(config: MetroConfigLike): NodeJS.Require {
245
+ const base =
246
+ typeof config.projectRoot === "string" && config.projectRoot.length !== 0
247
+ ? config.projectRoot
248
+ : process.cwd();
249
+ return createRequire(join(resolve(base), "package.json"));
250
+ }
251
+
175
252
  /**
176
253
  * Whether a `babelTransformerPath` already points at a `@ttsc/metro`
177
254
  * transformer — this copy or any other.
@@ -209,21 +286,15 @@ function isOwnTransformer(declared: string): boolean {
209
286
  /**
210
287
  * Whether two paths name the same file on disk.
211
288
  *
212
- * Resolved through `realpath` so a symlinked install and its target compare
213
- * equal, and case-folded on Windows, where `D:\` and `d:\` and a differently
214
- * cased base name all address one file.
289
+ * The shared resolver observes physical spelling and directory capabilities
290
+ * within this comparison. Symlinks can identify the same installed file while
291
+ * case-sensitive native names remain distinct.
215
292
  */
216
293
  function sameRealPath(left: string, right: string): boolean {
217
- const identity = (file: string): string => {
218
- let resolved = resolve(file);
219
- try {
220
- resolved = realpathSync.native(resolved);
221
- } catch {
222
- // Not on disk: the resolved spelling is the best identity available.
223
- }
224
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
225
- };
226
- return identity(left) === identity(right);
294
+ const identities = createFilesystemPathIdentityContext({
295
+ throwOnRealpathError: false,
296
+ });
297
+ return identities.resolve(left).key === identities.resolve(right).key;
227
298
  }
228
299
 
229
300
  /**
@@ -14,25 +14,33 @@
14
14
  * generation hit. Cross-file invalidation also rides the project fingerprint
15
15
  * {@link getCacheKey} folds into Metro's static transformer key (see
16
16
  * `core/fingerprint.ts`).
17
+ *
18
+ * The adapter passes no `watching` declaration in its hooks, so a project whose
19
+ * plugin observations are unavailable (a generation the core can only serve
20
+ * fresh) fails the transform with an explicit error instead of caching it: the
21
+ * core refuses fresh-only output unless the host states it is not watching.
17
22
  */
18
23
  import {
19
24
  createTtscTransformCache,
20
25
  isTransformTarget,
26
+ readTtscTransformSession,
21
27
  resolveOptions,
28
+ shareTtscTransformCache,
22
29
  transformTtsc,
23
30
  } from "@ttsc/unplugin/api";
24
- import { createHash } from "node:crypto";
31
+ import { createHash, randomBytes } from "node:crypto";
25
32
  import { createRequire } from "node:module";
26
33
  import path from "node:path";
27
34
 
35
+ import type { ResolvedTtscMetroOptions } from "./core/TtscMetroOptions";
28
36
  import {
29
37
  computeProjectFingerprint,
30
38
  createSnapshotRecorder,
31
39
  resolveProjectView,
32
40
  stableStringify,
33
41
  } from "./core/fingerprint";
34
- import type { ResolvedTtscMetroOptions } from "./core/options";
35
42
  import { resolveOptionsFromEnv } from "./core/options";
43
+ import { remapAstLocations } from "./core/remapAstLocations";
36
44
  import { resolveUpstreamTransformer } from "./core/upstream";
37
45
 
38
46
  const nodeRequire = createRequire(import.meta.url);
@@ -46,6 +54,9 @@ const nodeRequire = createRequire(import.meta.url);
46
54
  let resolved: ResolvedTtscMetroOptions | undefined;
47
55
  let unpluginOptions: ReturnType<typeof resolveOptions> | undefined;
48
56
  const cache = createTtscTransformCache();
57
+ // Metro's workers share each compile through the session `withTtsc` opened
58
+ // (samchon/ttsc#1390).
59
+ shareTtscTransformCache(cache, readTtscTransformSession());
49
60
  let snapshotRecorder: ReturnType<typeof createSnapshotRecorder> | undefined;
50
61
 
51
62
  /** Lazily resolve the worker-side options (from {@link resolveOptionsFromEnv}). */
@@ -70,6 +81,39 @@ function recorder(): ReturnType<typeof createSnapshotRecorder> {
70
81
  * monorepos and when Metro is launched from a parent directory. Getting this
71
82
  * wrong makes every file look "outside the project" and silently skips the
72
83
  * plugin pass.
84
+ *
85
+ * @evidence contracts/common.md#principled-implementation
86
+ * Node path.isAbsolute/path.resolve implement Metro's filename contract.
87
+ * Absolute input is retained; relative input is anchored at supplied
88
+ * projectRoot, with cwd only for callers lacking that option.
89
+ *
90
+ * @evidence contracts/common.md#clear-and-simple-design
91
+ * This helper owns the single relative-to-absolute boundary, keeping Metro's
92
+ * original filename separate from the compiler address.
93
+ *
94
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
95
+ * This operation chooses a base and delegates native path resolution;
96
+ * it owns no input collection or processing strategy.
97
+ *
98
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
99
+ * It maps one filename and supplied options without shared computation.
100
+ *
101
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
102
+ * The returned path transfers to its caller; no resource is retained.
103
+ *
104
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
105
+ * This correct owning anchor avoids monorepo misrouting without matching
106
+ * error text or special-casing fixture names.
107
+ *
108
+ * @evidence contracts/portability.md#os-neutral-implementation
109
+ * Node path handles native separators, roots and drive letters. No URLs or
110
+ * shell commands are mixed with filenames and no filesystem is changed.
111
+ *
112
+ * @evidence contracts/common.md#meaningful-documentation
113
+ * The native JSDoc explains the project-relative input, absolute result and
114
+ * cwd fallback reason. Checked against the documentation skill: separate
115
+ * paragraphs state the contract and why its nonobvious boundary matters;
116
+ * field comments retain their own useful facts.
73
117
  */
74
118
  export function resolveAbsoluteFilename(
75
119
  filename: string,
@@ -92,7 +136,60 @@ export function resolveAbsoluteFilename(
92
136
  * Expo/React-Native Babel transformer to produce the AST Metro expects. The
93
137
  * upstream call receives Metro's original params (notably the project-relative
94
138
  * `filename`, which Babel expects); only `src` is replaced with the
95
- * ttsc-transformed source.
139
+ * ttsc-transformed source. When the adapter returns a source map for that
140
+ * source, the upstream AST's locations are moved back through it to the
141
+ * author's lines, because Metro maps the AST against the file it read
142
+ * (samchon/ttsc#1392).
143
+ *
144
+ * An explicit `project` option selects its tsconfig. Otherwise the adapter
145
+ * discovers the nearest tsconfig and follows its references to the project
146
+ * admitting the file, using Metro's `projectRoot` or the current working
147
+ * directory to resolve a relative file.
148
+ *
149
+ * @evidence contracts/common.md#principled-implementation
150
+ * Metro's transformer callback composes the shared Unplugin transform core
151
+ * with the selected Babel transformer. One resolved project view is frozen
152
+ * into compilation and recorder inputs. Noneligible or out-of-program files
153
+ * follow the shared core contract; genuine compiler/load failures propagate.
154
+ *
155
+ * @evidence contracts/common.md#clear-and-simple-design
156
+ * The entry point gates delivery, freezes one project view for compilation
157
+ * and recording, then delegates to Babel. Shared core helpers own checking
158
+ * and caching; the adapter owns only Metro transport and AST remapping.
159
+ *
160
+ * @evidence contracts/performance.md#efficient-algorithms
161
+ * Filtering runs before compilation. Eligible deliveries resolve one view
162
+ * and record their inputs in a single batch; compiler and AST traversal
163
+ * costs belong to their delegated owners rather than repeated per input.
164
+ *
165
+ * @evidence contracts/performance.md#reuse-equivalent-work
166
+ * The worker's shared transform cache validates project state on each hit,
167
+ * while the inherited session shares compilation across workers. The same
168
+ * project view and options feed compilation and generation recording; Babel
169
+ * is invoked for the actual delivery and is not memoized by this adapter.
170
+ *
171
+ * @evidence contracts/performance.md#bound-retention-and-release-resources
172
+ * Worker singletons own resolved options, the transform cache and recorder
173
+ * until process termination. The shared core owns cached generations and
174
+ * session storage; the recorder owns its cumulative paths. This entry point
175
+ * exposes no disposal signal from Metro and does not claim a fixed byte bound.
176
+ *
177
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
178
+ * Only the returned AST's owned locations are updated, with no patched
179
+ * loader or test-specific production behavior.
180
+ *
181
+ * @evidence contracts/portability.md#os-neutral-implementation
182
+ * Metro-relative filenames drive substring filters; Node path.resolve
183
+ * produces absolute compiler addresses. The shared core owns native
184
+ * compiler/session access, while Babel retains the original filename.
185
+ * Worker transport is JSON and no shell command is constructed here.
186
+ *
187
+ * @evidence contracts/common.md#meaningful-documentation
188
+ * The native JSDoc explains pass order, original parameter preservation,
189
+ * project selection and AST location mutation. Checked against the
190
+ * documentation skill: separate paragraphs state the contract and why its
191
+ * nonobvious boundary matters; field comments retain their own useful
192
+ * facts.
96
193
  */
97
194
  export async function transform(params: {
98
195
  src: string;
@@ -112,6 +209,8 @@ export async function transform(params: {
112
209
  }
113
210
 
114
211
  let transformedSrc = params.src;
212
+ let transformedMap: { mappings: string; sources: string[] } | undefined;
213
+ let absoluteFilename = params.filename;
115
214
  {
116
215
  unpluginOptions ??= resolveOptions(opts.ttsc);
117
216
  const projectRoot =
@@ -121,6 +220,7 @@ export async function transform(params: {
121
220
  const explicitProject =
122
221
  typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined;
123
222
  const filename = resolveAbsoluteFilename(params.filename, params.options);
223
+ absoluteFilename = filename;
124
224
  const project = resolveProjectView({
125
225
  compilerOptions: opts.ttsc.compilerOptions,
126
226
  explicitProject,
@@ -174,10 +274,17 @@ export async function transform(params: {
174
274
  // Genuine compile and type failures still propagate so Metro surfaces them.
175
275
  if (result !== undefined && typeof result.code === "string") {
176
276
  transformedSrc = result.code;
277
+ transformedMap = result.map;
177
278
  }
178
279
  }
179
280
 
180
- return upstream.transform({ ...params, src: transformedSrc });
281
+ const output = await upstream.transform({ ...params, src: transformedSrc });
282
+ // Metro maps the returned AST against the file it read, so the upstream's
283
+ // positions in the transformed text are moved back to the author's lines.
284
+ if (transformedMap !== undefined) {
285
+ remapAstLocations(output.ast, transformedMap, absoluteFilename);
286
+ }
287
+ return output;
181
288
  }
182
289
 
183
290
  /**
@@ -199,8 +306,57 @@ export async function transform(params: {
199
306
  * A change to any fingerprinted input re-keys every transformed file at
200
307
  * project-level granularity, forced by Metro's single static key, replacing the
201
308
  * former manual `--reset-cache` step. Resolving the upstream is deliberately
202
- * non-fatal here: a missing peer must not crash cache-key computation. See the
203
- * README "Caveats" and samchon/ttsc#721.
309
+ * non-fatal here: a missing peer must not crash cache-key computation, but a
310
+ * failed upstream key withdraws reuse with a nonce. See the README "Caveats"
311
+ * and samchon/ttsc#721.
312
+ *
313
+ * @evidence contracts/common.md#principled-implementation
314
+ * Node sha256 combines package identity, stable resolved options, forwarded
315
+ * upstream key and the project fingerprint required by Metro's
316
+ * one-static-key contract. Failed upstream loads or callbacks contribute a
317
+ * nonce; an absent optional callback contributes no additional key.
318
+ * Forwarded compiler and plugin records retain their JSON property order
319
+ * because the compiler or a plugin can give that order semantic meaning.
320
+ * Fingerprint failure disables reuse through a nonce rather
321
+ * than fabricating a proven generation.
322
+ *
323
+ * @evidence contracts/common.md#clear-and-simple-design
324
+ * One hash assembly combines options, upstream contribution and the project
325
+ * fingerprint. The fingerprint owner handles persistence and validity;
326
+ * cacheKeyProjectRoot extracts Metro's argument without duplicating discovery.
327
+ *
328
+ * @evidence contracts/performance.md#efficient-algorithms
329
+ * One static key per Metro run hashes encoded options, the upstream key
330
+ * and one project fingerprint. Key assembly is linear in their string bytes;
331
+ * the fingerprint owner accounts for project scans and config traversal.
332
+ *
333
+ * @evidence contracts/performance.md#reuse-equivalent-work
334
+ * Options, adapter version, upstream key and exact project state contribute
335
+ * to the shared key. Project observation failures withdraw reuse through a
336
+ * nonce. A failing upstream load or key uses a nonce too, preserving the
337
+ * nonfatal keying boundary without pretending its external state is known.
338
+ *
339
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
340
+ * The hash is local to this call. Worker singletons are owned by transform
341
+ * and persisted baseline files by prepareSnapshot, not the hash assembler.
342
+ *
343
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
344
+ * Decision values come from the documented inputs and product protocol
345
+ * rather than expected test answers. No compensating path is introduced to
346
+ * make a known example pass.
347
+ *
348
+ * @evidence contracts/portability.md#os-neutral-implementation
349
+ * Native project roots are interpreted by the fingerprint owner and Node
350
+ * module loading reads package identity. Hash input has deterministic
351
+ * string representation across OSes; filesystem content and identity remain
352
+ * deliberately host-specific.
353
+ *
354
+ * @evidence contracts/common.md#meaningful-documentation
355
+ * The native JSDoc explains one key per run, all contributions,
356
+ * project-wide invalidation and nonfatal upstream key policy. Checked
357
+ * against the documentation skill: separate paragraphs state the contract
358
+ * and why its nonobvious boundary matters; field comments retain their own
359
+ * useful facts.
204
360
  */
205
361
  export function getCacheKey(...args: unknown[]): string {
206
362
  const opts = options();
@@ -208,16 +364,14 @@ export function getCacheKey(...args: unknown[]): string {
208
364
  hash.update(`@ttsc/metro:${packageVersion()}`);
209
365
  hash.update(
210
366
  stableStringify({
211
- ttsc: opts.ttsc,
367
+ ttsc: JSON.stringify(opts.ttsc),
212
368
  include: opts.include,
213
369
  exclude: opts.exclude,
214
370
  upstreamTransformer: opts.upstreamTransformer ?? null,
215
371
  }),
216
372
  );
217
373
  const upstreamKey = upstreamCacheKey(opts.upstreamTransformer, args);
218
- if (upstreamKey.length !== 0) {
219
- hash.update(upstreamKey);
220
- }
374
+ hash.update(upstreamKey ?? `nonce:${randomBytes(32).toString("hex")}`);
221
375
  hash.update(
222
376
  computeProjectFingerprint({
223
377
  // The same overlay `transform` hands the recorder. Both read these
@@ -255,18 +409,19 @@ function cacheKeyProjectRoot(args: unknown[]): string | undefined {
255
409
  * Fold the upstream transformer's cache key in, defensively. Forwards Metro's
256
410
  * own `getCacheKey` arguments so the upstream's babelrc-derived key is
257
411
  * preserved, and never throws: a missing peer or a throwing upstream
258
- * `getCacheKey` yields an empty contribution rather than failing the whole
259
- * build's cache keying.
412
+ * `getCacheKey` yields `undefined`, withdrawing cross-run reuse without failing
413
+ * the whole build's cache keying. An absent optional callback still contributes
414
+ * the empty string.
260
415
  */
261
416
  function upstreamCacheKey(
262
417
  upstreamTransformer: string | undefined,
263
418
  args: unknown[],
264
- ): string {
419
+ ): string | undefined {
265
420
  let upstream;
266
421
  try {
267
422
  upstream = resolveUpstreamTransformer(upstreamTransformer);
268
423
  } catch {
269
- return "";
424
+ return undefined;
270
425
  }
271
426
  if (upstream.getCacheKey === undefined) {
272
427
  return "";
@@ -274,15 +429,57 @@ function upstreamCacheKey(
274
429
  try {
275
430
  return String(upstream.getCacheKey(...args) ?? "");
276
431
  } catch {
277
- return "";
432
+ return undefined;
278
433
  }
279
434
  }
280
435
 
281
436
  /**
282
437
  * Decide whether a file should run through the ttsc pass. Only TypeScript
283
438
  * sources (`.ts`/`.tsx`/`.mts`/`.cts`, excluding every declaration form)
284
- * qualify; `exclude` substrings win over `include`, and an empty `include`
285
- * means "all TypeScript". Exported for unit testing.
439
+ * qualify, and a file below a `node_modules` directory never does, whatever
440
+ * `include` says, because the shared `isTransformTarget` predicate rejects it
441
+ * first. `exclude` substrings win over `include`, and an empty `include` means
442
+ * "all eligible TypeScript". Patterns use the supplied project-relative
443
+ * filename literally; this operation does not normalize separators or
444
+ * filesystem case.
445
+ *
446
+ * @evidence contracts/common.md#principled-implementation
447
+ * The shared isTransformTarget predicate owns supported TypeScript
448
+ * extensions, declaration exclusions, virtual-module and node_modules
449
+ * exclusions. Literal substring filters apply to Metro's project-relative
450
+ * filename, with exclusion taking precedence.
451
+ *
452
+ * @evidence contracts/common.md#clear-and-simple-design
453
+ * This predicate orders extension eligibility, exclusion and inclusion as
454
+ * early returns. Compiler execution remains outside the filtering policy.
455
+ *
456
+ * @evidence contracts/performance.md#efficient-algorithms
457
+ * Extension matching precedes substring scans; exclusion returns early and
458
+ * inclusion stops at its first match. Cost follows pattern count and supplied
459
+ * filename length without allocating a normalized copy for literal matching.
460
+ *
461
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
462
+ * This inexpensive predicate coordinates no shared computation and accepts
463
+ * caller-owned, potentially mutable option arrays.
464
+ *
465
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
466
+ * Filtering retains no caller data and opens no native resource.
467
+ *
468
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
469
+ * This pure decision neither invokes compilation nor mutates options, and
470
+ * has no fixture or test-mode branch.
471
+ *
472
+ * @evidenceExclude contracts/portability.md#os-neutral-implementation
473
+ * This predicate matches TypeScript extensions and caller-supplied literal
474
+ * substrings. It defines no filesystem identity or native path
475
+ * normalization contract.
476
+ *
477
+ * @evidence contracts/common.md#meaningful-documentation
478
+ * The native JSDoc explains eligible extensions, declaration and
479
+ * node_modules exclusion, empty include and exclusion precedence. Checked
480
+ * against the documentation skill: separate paragraphs state the contract
481
+ * and why its nonobvious boundary matters; field comments retain their own
482
+ * useful facts.
286
483
  */
287
484
  export function shouldTransform(
288
485
  filename: string,