@ttsc/metro 0.28.2 → 0.28.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/metro",
3
- "version": "0.28.2",
3
+ "version": "0.28.4",
4
4
  "description": "Metro (React Native / Expo) adapter for ttsc plugins.",
5
5
  "main": "lib/index.js",
6
6
  "module": "lib/index.mjs",
@@ -35,7 +35,7 @@
35
35
  "src"
36
36
  ],
37
37
  "dependencies": {
38
- "@ttsc/unplugin": "^0.28.2"
38
+ "@ttsc/unplugin": "^0.28.4"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@expo/metro-config": "*",
@@ -62,7 +62,7 @@
62
62
  "tinyglobby": "^0.2.16",
63
63
  "tslib": "^2.8.1",
64
64
  "typescript": "^7.0.2",
65
- "ttsc": "0.28.2"
65
+ "ttsc": "0.28.4"
66
66
  },
67
67
  "repository": {
68
68
  "type": "git",
@@ -48,6 +48,8 @@ import {
48
48
  collectExternalInputHashes,
49
49
  collectProjectInputHashes,
50
50
  isProjectWalkPath,
51
+ mergeMembershipPolicyOverlay,
52
+ readProjectMembershipPolicy,
51
53
  } from "@ttsc/unplugin/api";
52
54
  import { createHash, randomBytes } from "node:crypto";
53
55
  import fs from "node:fs";
@@ -131,10 +133,152 @@ export function fingerprintRoots(
131
133
  explicitProject: string | undefined,
132
134
  ): string[] {
133
135
  const tsconfig = resolveProjectTsconfig(base, explicitProject);
134
- if (isProjectWalkPath(base, tsconfig)) {
135
- return [base];
136
+ // Containment, not walk membership. The question here is whether the
137
+ // tsconfig's directory already sits inside the subtree the base walk covers,
138
+ // so that adding it would repeat the same walk. `isProjectWalkPath` answers a
139
+ // different question, whether the walk *hashes* that path, and once the walk
140
+ // stopped hashing files that cannot enter the program it began answering
141
+ // `false` for every `tsconfig.json`, which returned the base twice and hashed
142
+ // the whole project twice on every cache key (samchon/ttsc#1307).
143
+ const directory = path.dirname(path.resolve(tsconfig));
144
+ const relative = path.relative(path.resolve(base), directory);
145
+ const inside =
146
+ relative === "" ||
147
+ (relative !== ".." &&
148
+ !relative.startsWith(`..${path.sep}`) &&
149
+ !path.isAbsolute(relative));
150
+ return inside ? [base] : [base, directory];
151
+ }
152
+
153
+ /**
154
+ * One project, and the membership policy that describes it.
155
+ *
156
+ * The recorder's question is whether the project walk already covers an input,
157
+ * so it needs both the walk's roots and the policy that walk used, and it is
158
+ * wrong exactly when those two describe different projects. Passing them
159
+ * separately made that mismatch expressible — the policy for one project
160
+ * alongside the root of another — and passing the policy alone made it
161
+ * expressible in a quieter way still, since a recorder that resolved its own
162
+ * could describe a different program than the walk hashed. Both halves travel
163
+ * together so neither can be supplied without the other (samchon/ttsc#1316).
164
+ */
165
+ export interface TtscMetroProjectView {
166
+ /** The base directory both fingerprint sides agree on. */
167
+ readonly base: string;
168
+ /** The caller's explicit `project`, if any. */
169
+ readonly explicitProject: string | undefined;
170
+ /** The membership policy resolved for that project. */
171
+ readonly policy: ReturnType<typeof readProjectMembershipPolicy>;
172
+ }
173
+
174
+ /**
175
+ * Resolve one transform's project view, once, for every watch input it reports.
176
+ *
177
+ * Exported because the recorder is asked once per input while the answer is a
178
+ * property of the project, and validating the memo means stat-ing the whole
179
+ * `extends` chain (samchon/ttsc#1316).
180
+ */
181
+ export function resolveProjectView(props: {
182
+ compilerOptions?: Record<string, unknown>;
183
+ explicitProject?: string;
184
+ projectRoot?: string;
185
+ }): TtscMetroProjectView {
186
+ const base = resolveFingerprintBase(props.projectRoot);
187
+ return {
188
+ base,
189
+ explicitProject: props.explicitProject,
190
+ policy: membershipPolicy(
191
+ resolveProjectTsconfig(base, props.explicitProject),
192
+ props.compilerOptions,
193
+ ),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * The membership policy of one project, memoized per resolved tsconfig and
199
+ * caller overlay.
200
+ *
201
+ * Every use of the walk pair has to ask the same policy, or the two halves
202
+ * disagree about the same project. The walk hashes what the configuration can
203
+ * admit, and `isProjectWalkPath` answers whether the walk covers a path, so a
204
+ * permissive answer here would claim coverage the walk does not provide and the
205
+ * input would be recorded nowhere at all (samchon/ttsc#1307).
206
+ */
207
+ const MEMBERSHIP_POLICIES = new Map<
208
+ string,
209
+ {
210
+ policy: ReturnType<typeof readProjectMembershipPolicy>;
211
+ sources: readonly string[];
212
+ stamp: string;
136
213
  }
137
- return [base, path.dirname(tsconfig)];
214
+ >();
215
+
216
+ function membershipPolicy(
217
+ tsconfig: string,
218
+ compilerOptions?: Record<string, unknown>,
219
+ ): ReturnType<typeof readProjectMembershipPolicy> {
220
+ // Keyed by the config's path and the caller's overlay, and never trusted on
221
+ // the key alone: a hit is served only while the config's own stamp still
222
+ // matches. A Metro worker outlives many runs, and this is consulted once per
223
+ // delivered file, so a memo that trusted its key would hold the policy a
224
+ // project had when the worker started. An edit adding `exclude` would then
225
+ // leave the worker judging a file in-walk while the next run's walk skipped
226
+ // it, which is precisely the both-sides-disagree hole this policy exists to
227
+ // close.
228
+ //
229
+ // The overlay belongs in the key rather than the stamp because it is part of
230
+ // the question, not part of any file: keyed by path alone the memo would hand
231
+ // a caller who passed `allowJs` the policy resolved for a caller who did not.
232
+ const key = [tsconfig, stableStringify(compilerOptions ?? {})].join(
233
+ String.fromCharCode(0),
234
+ );
235
+ const existing = MEMBERSHIP_POLICIES.get(key);
236
+ if (existing !== undefined && existing.stamp === stampOf(existing.sources)) {
237
+ return existing.policy;
238
+ }
239
+ // The caller's compiler-options overlay wins for the compile, so it has to
240
+ // win here too, exactly as it does in the adapter: a project given
241
+ // `allowJs: true` through `withTtsc` has a wider program than its tsconfig
242
+ // alone describes, and a narrower policy here would ask a different question
243
+ // about the same project (samchon/ttsc#1316).
244
+ const policy = mergeMembershipPolicyOverlay(
245
+ readProjectMembershipPolicy(tsconfig),
246
+ compilerOptions ?? {},
247
+ path.dirname(path.resolve(tsconfig)),
248
+ );
249
+ // Stamp the whole `extends` chain, not the leaf. Adding `exclude` to a shared
250
+ // `tsconfig.base.json` leaves the leaf's own mtime and size untouched while
251
+ // changing every answer the policy gives, so a leaf-only stamp would keep the
252
+ // worker on the pre-edit policy for its lifetime.
253
+ const sources =
254
+ policy.sources.length === 0 ? [tsconfig] : [...policy.sources];
255
+ MEMBERSHIP_POLICIES.set(key, {
256
+ policy,
257
+ sources,
258
+ stamp: stampOf(sources),
259
+ });
260
+ return policy;
261
+ }
262
+
263
+ /** A stamp over every config a policy was read from, in a stable order. */
264
+ function stampOf(sources: readonly string[]): string {
265
+ return sources
266
+ .map((source) => {
267
+ try {
268
+ const stats = fs.statSync(source);
269
+ // A directory occupying a candidate path can never be the config, so it
270
+ // contributes its existence and not its modification time, which moves
271
+ // whenever any child is added or removed (samchon/ttsc#1316).
272
+ return stats.isDirectory()
273
+ ? `${source}:directory`
274
+ : `${source}:${stats.mtimeMs}:${stats.size}`;
275
+ } catch {
276
+ // Absent now. `readProjectMembershipPolicy` answers for that too, and
277
+ // the policy must be re-asked once the config appears.
278
+ return `${source}:absent`;
279
+ }
280
+ })
281
+ .join("|");
138
282
  }
139
283
 
140
284
  /**
@@ -173,14 +317,36 @@ function resolveProjectTsconfig(
173
317
  * cross-run cache reuse for this run instead of serving stale output.
174
318
  */
175
319
  export function computeProjectFingerprint(props: {
320
+ compilerOptions?: Record<string, unknown>;
176
321
  explicitProject?: string;
177
322
  projectRoot?: string;
178
323
  }): string {
179
324
  try {
180
325
  const base = resolveFingerprintBase(props.projectRoot);
181
326
  const hash = createHash("sha256");
327
+ // Judge the fingerprint's walk by the same configuration the compile
328
+ // does. Metro folds this into one static key, so an entry the program
329
+ // could never contain used to re-key every transformed file rather than
330
+ // costing one compile the way it does for a bundler (samchon/ttsc#1307).
331
+ //
332
+ // The caller's compiler-options overlay is part of that configuration, and
333
+ // has to reach the walk as well as the recorder. The two are the halves of
334
+ // one cache key and run in different processes, so they agree only by
335
+ // deriving from the same declared options: a walk resolved without the
336
+ // overlay while the recorder resolves with it leaves an overlay-admitted
337
+ // input in neither half, which is the both-sides-disagree hole in its
338
+ // quietest form (samchon/ttsc#1316).
339
+ const project = resolveProjectView({
340
+ compilerOptions: props.compilerOptions,
341
+ explicitProject: props.explicitProject,
342
+ projectRoot: props.projectRoot,
343
+ });
182
344
  for (const root of fingerprintRoots(base, props.explicitProject)) {
183
- hash.update(stableStringify(collectProjectInputHashes(root)));
345
+ hash.update(
346
+ stableStringify(
347
+ collectProjectInputHashes(root, undefined, undefined, project.policy),
348
+ ),
349
+ );
184
350
  }
185
351
  const snapshot = readSnapshotState(base);
186
352
  if (snapshot === undefined || snapshot.volatile) {
@@ -386,14 +552,29 @@ export function readSnapshotState(base: string): SnapshotState | undefined {
386
552
  */
387
553
  export function createSnapshotRecorder(): {
388
554
  record: (props: {
389
- explicitProject?: string;
390
555
  input: string;
391
- projectRoot?: string;
392
- }) => void;
393
- recordVolatile: (props: {
394
- explicitProject?: string;
395
- projectRoot?: string;
556
+ /**
557
+ * The project this input belongs to, with the policy its walk uses,
558
+ * resolved once per transform through {@link resolveProjectView}.
559
+ *
560
+ * One value rather than a root and a policy side by side, because the
561
+ * recorder is wrong precisely when those two describe different projects.
562
+ * Deriving the policy here instead would be the same fault in a quieter
563
+ * form: a recorder that resolved its own could describe a different program
564
+ * than the walk hashed, which is what happened when the caller's
565
+ * compiler-options overlay reached one half and not the other, and the
566
+ * input was then covered by neither (samchon/ttsc#1316).
567
+ *
568
+ * Resolving it once per transform is also what makes it affordable.
569
+ * `record` runs once per watch input rather than once per file, and
570
+ * validating the memo means stat-ing the whole `extends` chain, measured at
571
+ * 12 microseconds per stat — a few thousand modules times fifteen inputs
572
+ * each cost over half a second per run for an answer that cannot change
573
+ * between two inputs of one file.
574
+ */
575
+ project: TtscMetroProjectView;
396
576
  }) => void;
577
+ recordVolatile: (props: { project: TtscMetroProjectView }) => void;
397
578
  } {
398
579
  const suffix = `${process.pid.toString(36)}-${randomBytes(6).toString("hex")}`;
399
580
  interface BaseState {
@@ -405,18 +586,15 @@ export function createSnapshotRecorder(): {
405
586
  }
406
587
  const states = new Map<string, BaseState>();
407
588
 
408
- function stateFor(
409
- projectRoot: string | undefined,
410
- explicitProject: string | undefined,
411
- ): BaseState {
412
- const base = resolveFingerprintBase(projectRoot);
589
+ function stateFor(project: TtscMetroProjectView): BaseState {
590
+ const base = project.base;
413
591
  let state = states.get(base);
414
592
  if (state === undefined) {
415
593
  state = {
416
594
  dirty: false,
417
595
  files: new Set(),
418
596
  observed: false,
419
- roots: fingerprintRoots(base, explicitProject),
597
+ roots: fingerprintRoots(base, project.explicitProject),
420
598
  volatile: false,
421
599
  };
422
600
  states.set(base, state);
@@ -459,15 +637,23 @@ export function createSnapshotRecorder(): {
459
637
 
460
638
  return {
461
639
  record(props) {
462
- const base = resolveFingerprintBase(props.projectRoot);
463
- const state = stateFor(props.projectRoot, props.explicitProject);
640
+ const base = props.project.base;
641
+ const state = stateFor(props.project);
464
642
  const input = path.resolve(props.input);
465
643
  const firstObservation = !state.observed;
466
644
  state.observed = true;
467
645
  if (
468
646
  state.files.has(input) ||
469
647
  (fs.existsSync(input) &&
470
- state.roots.some((root) => isProjectWalkPath(root, input)))
648
+ state.roots.some((root) =>
649
+ isProjectWalkPath(
650
+ root,
651
+ input,
652
+ undefined,
653
+ undefined,
654
+ props.project.policy,
655
+ ),
656
+ ))
471
657
  ) {
472
658
  // Even when every input belongs to the project walk, the worker must
473
659
  // publish that it performed a clean transform. Otherwise an old main
@@ -483,8 +669,8 @@ export function createSnapshotRecorder(): {
483
669
  flush(base, state);
484
670
  },
485
671
  recordVolatile(props) {
486
- const base = resolveFingerprintBase(props.projectRoot);
487
- const state = stateFor(props.projectRoot, props.explicitProject);
672
+ const base = props.project.base;
673
+ const state = stateFor(props.project);
488
674
  if (state.volatile) {
489
675
  flush(base, state);
490
676
  return;
package/src/index.ts CHANGED
@@ -27,7 +27,9 @@
27
27
  * module.exports = withTtsc(getDefaultConfig(__dirname));
28
28
  * ```
29
29
  */
30
- import { dirname, join } from "node:path";
30
+ import { readFileSync, realpathSync } from "node:fs";
31
+ import { createRequire } from "node:module";
32
+ import { basename, dirname, join, resolve } from "node:path";
31
33
  import { fileURLToPath } from "node:url";
32
34
 
33
35
  import { prepareSnapshot } from "./core/fingerprint";
@@ -64,12 +66,22 @@ interface MetroConfigLike {
64
66
  * With no `options`, the transformer auto-discovers `tsconfig.json` and runs
65
67
  * the plugins configured there: the standard ttsc model. Pass `options` only to
66
68
  * override the project path, plugin list, or include/exclude filters.
69
+ *
70
+ * A `babelTransformerPath` the config already carried is chained rather than
71
+ * replaced: it becomes the upstream this transformer delegates to, so wrapping
72
+ * a working config keeps whatever it configured. `react-native-svg-transformer`
73
+ * is installed by exactly that assignment, and replacing it silently sent every
74
+ * `.svg` to the auto-detected Expo default instead, with the build still
75
+ * succeeding (samchon/ttsc#1321). An explicit `upstreamTransformer` option
76
+ * still wins, since that is the caller saying it outright.
67
77
  */
68
78
  export function withTtsc<T extends MetroConfigLike>(
69
79
  config: T,
70
80
  options: TtscMetroOptions = {},
71
81
  ): T {
72
- process.env[ENV_KEY] = serializeOptions(options);
82
+ process.env[ENV_KEY] = serializeOptions(
83
+ inheritConfiguredTransformer(config, options),
84
+ );
73
85
  // Prepare the reference-graph snapshot backing the transformer's cache-key
74
86
  // fingerprint (see `core/fingerprint.ts`). This runs in the single Metro
75
87
  // config process before any worker exists, so it is the race-free moment to
@@ -86,6 +98,133 @@ export function withTtsc<T extends MetroConfigLike>(
86
98
  } as T;
87
99
  }
88
100
 
101
+ /**
102
+ * Adopt the config's own `babelTransformerPath` as the upstream to delegate to.
103
+ *
104
+ * The value `withTtsc` overwrites is precisely the transformer that should run
105
+ * after the ttsc pass, so taking it as the default `upstreamTransformer` is
106
+ * what makes the wrapper additive in the one field it sets. Everything else in
107
+ * the config was already spread through untouched, which is what made the loss
108
+ * hard to see (samchon/ttsc#1321).
109
+ *
110
+ * An explicit option wins, and this package's own transformer is never adopted:
111
+ * a config wrapped twice would otherwise name this module as its own upstream
112
+ * and delegate into itself.
113
+ */
114
+ function inheritConfiguredTransformer(
115
+ config: MetroConfigLike,
116
+ options: TtscMetroOptions,
117
+ ): TtscMetroOptions {
118
+ const declared = config.transformer?.babelTransformerPath;
119
+ if (
120
+ options.upstreamTransformer !== undefined ||
121
+ typeof declared !== "string" ||
122
+ declared.length === 0
123
+ ) {
124
+ return options;
125
+ }
126
+ // Resolve before judging. Ownership is a property of the module, not of the
127
+ // string, and every spelling has to become one absolute path before either
128
+ // question can be answered honestly.
129
+ const resolved = resolveFromProject(declared, config);
130
+ if (isOwnTransformer(resolved)) {
131
+ return options;
132
+ }
133
+ return { ...options, upstreamTransformer: resolved };
134
+ }
135
+
136
+ /**
137
+ * Resolve a declared `babelTransformerPath` the way Metro would: from the
138
+ * project.
139
+ *
140
+ * Metro resolves this value against the project, while the worker resolves
141
+ * `upstreamTransformer` with a `require` rooted in this package's own
142
+ * `lib/core`. Those are different places, so passing the caller's spelling
143
+ * through unchanged asks the worker to find the module somewhere it was never
144
+ * meant to be. Resolving here, once, in the config process that still knows the
145
+ * project root, removes the ambiguity for every spelling at once:
146
+ *
147
+ * - A relative `./metro-svg.cjs` becomes the file the caller meant, instead of
148
+ * one looked for inside `@ttsc/metro` and not found;
149
+ * - A bare `react-native-svg-transformer` becomes its real location, which
150
+ * matters under pnpm, where this package sits in a virtual store and walking
151
+ * up from it never reaches the project's own `node_modules`;
152
+ * - An absolute path resolves to itself, unchanged;
153
+ * - And `require.resolve("@ttsc/metro/transformer")` — a caller who wired this
154
+ * package by hand — becomes a path {@link isOwnTransformer} can recognise,
155
+ * which no comparison against the bare specifier could.
156
+ *
157
+ * A specifier that cannot be resolved is handed on exactly as written. It may
158
+ * still resolve in the worker, and if it does not, `resolveUpstreamTransformer`
159
+ * names it in an error; inventing a path here would only move the failure
160
+ * somewhere less legible.
161
+ */
162
+ function resolveFromProject(declared: string, config: MetroConfigLike): string {
163
+ const base =
164
+ typeof config.projectRoot === "string" && config.projectRoot.length !== 0
165
+ ? config.projectRoot
166
+ : process.cwd();
167
+ try {
168
+ return createRequire(join(resolve(base), "package.json")).resolve(declared);
169
+ } catch {
170
+ return declared;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Whether a `babelTransformerPath` already points at a `@ttsc/metro`
176
+ * transformer — this copy or any other.
177
+ *
178
+ * Adopting one would make this transformer its own upstream. That is not merely
179
+ * redundant: the worker options are process-global, so the adopted copy reads
180
+ * the same `TTSC_METRO_OPTIONS`, finds itself named there, and recurses until
181
+ * the stack ends. Comparing directory strings was not enough, because a second
182
+ * installed copy — a differently hoisted `node_modules`, a shared config
183
+ * package that already wrapped — lives in a different directory and passed the
184
+ * check.
185
+ *
186
+ * So the question asked is "is this module a `@ttsc/metro` transformer", not
187
+ * "is this string our path": the real path settles the same-copy case through
188
+ * symlinks and drive-letter spellings, and the owning `package.json` settles
189
+ * every other copy.
190
+ */
191
+ function isOwnTransformer(declared: string): boolean {
192
+ const candidate = resolve(declared);
193
+ if (sameRealPath(candidate, transformerModulePath())) {
194
+ return true;
195
+ }
196
+ if (!/^transformer\.(?:js|mjs|cjs)$/i.test(basename(candidate))) {
197
+ return false;
198
+ }
199
+ try {
200
+ const manifest = join(dirname(dirname(candidate)), "package.json");
201
+ return JSON.parse(readFileSync(manifest, "utf8")).name === "@ttsc/metro";
202
+ } catch {
203
+ // No readable manifest beside it, so nothing identifies it as ours.
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Whether two paths name the same file on disk.
210
+ *
211
+ * Resolved through `realpath` so a symlinked install and its target compare
212
+ * equal, and case-folded on Windows, where `D:\` and `d:\` and a differently
213
+ * cased base name all address one file.
214
+ */
215
+ function sameRealPath(left: string, right: string): boolean {
216
+ const identity = (file: string): string => {
217
+ let resolved = resolve(file);
218
+ try {
219
+ resolved = realpathSync.native(resolved);
220
+ } catch {
221
+ // Not on disk: the resolved spelling is the best identity available.
222
+ }
223
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
224
+ };
225
+ return identity(left) === identity(right);
226
+ }
227
+
89
228
  /**
90
229
  * Absolute path to the built transformer module Metro will `require`.
91
230
  *
@@ -27,6 +27,7 @@ import path from "node:path";
27
27
  import {
28
28
  computeProjectFingerprint,
29
29
  createSnapshotRecorder,
30
+ resolveProjectView,
30
31
  stableStringify,
31
32
  } from "./core/fingerprint";
32
33
  import type { ResolvedTtscMetroOptions } from "./core/options";
@@ -112,7 +113,7 @@ export async function transform(params: {
112
113
  }
113
114
 
114
115
  let transformedSrc = params.src;
115
- try {
116
+ {
116
117
  unpluginOptions ??= resolveOptions(opts.ttsc);
117
118
  const projectRoot =
118
119
  typeof params.options.projectRoot === "string"
@@ -120,6 +121,11 @@ export async function transform(params: {
120
121
  : undefined;
121
122
  const explicitProject =
122
123
  typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined;
124
+ const project = resolveProjectView({
125
+ compilerOptions: opts.ttsc.compilerOptions,
126
+ explicitProject,
127
+ projectRoot,
128
+ });
123
129
  const result = await transformTtsc(
124
130
  resolveAbsoluteFilename(params.filename, params.options),
125
131
  params.src,
@@ -133,25 +139,29 @@ export async function transform(params: {
133
139
  // that the next run's getCacheKey re-hashes instead. Fires on cache
134
140
  // hits too, so a worker that never recompiled still records the
135
141
  // inputs backing the outputs it serves.
136
- addWatchFile: (input) =>
137
- snapshotRecorder.record({ explicitProject, input, projectRoot }),
142
+ // The project view is resolved once for this file and handed to every
143
+ // one of its watch inputs. `record` runs per input, and validating the
144
+ // memo means stat-ing the whole `extends` chain, which is an answer
145
+ // that cannot change between two inputs of one file
146
+ // (samchon/ttsc#1316).
147
+ addWatchFile: (input) => snapshotRecorder.record({ input, project }),
138
148
  // A volatile declaration means the output depends on non-file inputs
139
149
  // that no file fingerprint can represent; the snapshot marks it and
140
150
  // getCacheKey degrades to a per-run nonce (no cross-run reuse).
141
- markVolatile: () =>
142
- snapshotRecorder.recordVolatile({ explicitProject, projectRoot }),
151
+ markVolatile: () => snapshotRecorder.recordVolatile({ project }),
143
152
  },
144
153
  );
154
+ // A file the program does not contain comes back as `undefined` from the
155
+ // shared transform, exactly as an unchanged one does, so it passes through
156
+ // here with no special case. That decision belongs to
157
+ // `@ttsc/unplugin`'s core and is shared with every bundler adapter; this
158
+ // transformer used to hold its own copy of it, recognising the case by
159
+ // searching the error text for "did not return output" while the adapters
160
+ // failed the build for the identical condition (samchon/ttsc#1308).
161
+ // Genuine compile and type failures still propagate so Metro surfaces them.
145
162
  if (result !== undefined && typeof result.code === "string") {
146
163
  transformedSrc = result.code;
147
164
  }
148
- } catch (error) {
149
- // A file that is not part of the tsconfig program is not a build error,
150
- // pass it through untransformed. Genuine compile/type failures propagate so
151
- // Metro surfaces them, matching the other ttsc bundler integrations.
152
- if (!isFileOutsideProject(error)) {
153
- throw error;
154
- }
155
165
  }
156
166
 
157
167
  return upstream.transform({ ...params, src: transformedSrc });
@@ -198,6 +208,10 @@ export function getCacheKey(...args: unknown[]): string {
198
208
  }
199
209
  hash.update(
200
210
  computeProjectFingerprint({
211
+ // The same overlay `transform` hands the recorder. Both read these
212
+ // options from `options()`, so the walk and the recorder judge one
213
+ // project by one program (samchon/ttsc#1316).
214
+ compilerOptions: opts.ttsc.compilerOptions,
201
215
  explicitProject:
202
216
  typeof opts.ttsc.project === "string" ? opts.ttsc.project : undefined,
203
217
  projectRoot: cacheKeyProjectRoot(args),
@@ -276,16 +290,6 @@ export function shouldTransform(
276
290
  return true;
277
291
  }
278
292
 
279
- /**
280
- * `transformTtsc` throws `"ttsc transform did not return output for <file>"`
281
- * when the requested file is not part of the compiled program (e.g. excluded
282
- * from the tsconfig). That case is non-fatal: the file should pass through.
283
- */
284
- function isFileOutsideProject(error: unknown): boolean {
285
- const message = error instanceof Error ? error.message : String(error);
286
- return message.includes("did not return output");
287
- }
288
-
289
293
  function packageVersion(): string {
290
294
  try {
291
295
  const pkg = nodeRequire("@ttsc/metro/package.json") as { version?: string };