@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
@@ -1,61 +1,7 @@
1
- import type { TtscUnpluginOptions } from "@ttsc/unplugin/api";
2
-
3
- /**
4
- * Options accepted by {@link withTtsc} and the Metro transformer.
5
- *
6
- * The `project` / `compilerOptions` / `plugins` fields are inherited from
7
- * `@ttsc/unplugin` so the Metro adapter speaks the exact same configuration
8
- * language as every other bundler integration. The remaining fields are
9
- * Metro-specific.
10
- *
11
- * Every field is JSON-serialisable on purpose: `withTtsc` runs in the Metro
12
- * **config** process, but the transformer runs in Metro's **worker** processes,
13
- * so the resolved options have to survive a structured-clone / env round-trip
14
- * to reach them (see {@link serializeOptions}). That is why `include`/`exclude`
15
- * are plain substring patterns rather than `RegExp`.
16
- */
17
- export interface TtscMetroOptions extends TtscUnpluginOptions {
18
- /**
19
- * Explicit module path of the upstream Metro Babel transformer to delegate to
20
- * after the ttsc pass.
21
- *
22
- * When omitted it is auto-detected: `@expo/metro-config/babel-transformer`
23
- * first (Expo), then `@react-native/metro-babel-transformer`, then the legacy
24
- * `metro-react-native-babel-transformer`.
25
- */
26
- upstreamTransformer?: string;
27
-
28
- /**
29
- * Substring patterns; when non-empty only files whose path contains one of
30
- * them are run through the ttsc pass. Non-matching files are passed straight
31
- * to the upstream transformer.
32
- */
33
- include?: string[];
34
-
35
- /**
36
- * Substring patterns; files whose path contains one of them skip the ttsc
37
- * pass and go straight to the upstream transformer. Applied after
38
- * {@link include}.
39
- */
40
- exclude?: string[];
41
- }
42
-
43
- /**
44
- * Fully-resolved options, split into the ttsc-side overlay and Metro-side
45
- * knobs.
46
- */
47
- export interface ResolvedTtscMetroOptions {
48
- /** Options forwarded verbatim to the `@ttsc/unplugin` transform core. */
49
- ttsc: TtscUnpluginOptions;
50
- /** Explicit upstream transformer module path, or `undefined` to auto-detect. */
51
- upstreamTransformer?: string;
52
- /** Resolved include patterns (never `undefined`). */
53
- include: string[];
54
- /** Resolved exclude patterns (never `undefined`). */
55
- exclude: string[];
56
- /** Private run identity shared by `getCacheKey` and Metro workers. */
57
- snapshotRunId?: string;
58
- }
1
+ import type {
2
+ ResolvedTtscMetroOptions,
3
+ TtscMetroOptions,
4
+ } from "./TtscMetroOptions";
59
5
 
60
6
  /**
61
7
  * Environment variable that carries the resolved options from the Metro config
@@ -72,6 +18,42 @@ export const ENV_KEY = "TTSC_METRO_OPTIONS";
72
18
  /**
73
19
  * Serialise user options for transport to the worker processes via
74
20
  * {@link ENV_KEY}.
21
+ *
22
+ * A supplied private run identity is written into the payload. This function
23
+ * returns JSON without publishing the environment variable or mutating options.
24
+ * Values must be JSON-serialisable; serialization errors propagate.
25
+ *
26
+ * @evidence contracts/common.md#principled-implementation
27
+ * JSON.stringify and object spread encode the declared worker transport.
28
+ * JSON failures propagate rather than inventing a successful payload.
29
+ *
30
+ * @evidence contracts/common.md#clear-and-simple-design
31
+ * A single serializer owns the config-to-worker payload. Publication stays
32
+ * with withTtsc, so encoding has no environment side effect.
33
+ *
34
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
35
+ * A supplied private run identity overrides that transport field; this
36
+ * operation returns bytes and does not replace process methods, loader
37
+ * internals or caller options.
38
+ *
39
+ * @evidence contracts/common.md#meaningful-documentation
40
+ * The native JSDoc explains the JSON payload, supplied run identity and
41
+ * serialization failure boundary. Checked against the documentation skill:
42
+ * separate paragraphs state the contract and why its nonobvious boundary
43
+ * matters; field comments retain their own useful facts.
44
+ *
45
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
46
+ * Returns a string and keeps no state or handle.
47
+ *
48
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
49
+ * One JSON.stringify of the user's small options record per config load.
50
+ *
51
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
52
+ * Runs once per Metro config load; there is no repeated work to share.
53
+ *
54
+ * @evidenceExclude contracts/portability.md#os-neutral-implementation
55
+ * JSON.stringify escapes backslashes, so a Windows path in the options
56
+ * round-trips; no path is interpreted.
75
57
  */
76
58
  export function serializeOptions(
77
59
  options: TtscMetroOptions,
@@ -86,10 +68,50 @@ export function serializeOptions(
86
68
  /**
87
69
  * Reconstruct the resolved options inside a worker process.
88
70
  *
89
- * Reads {@link ENV_KEY}; when it is unset or malformed the adapter falls back to
90
- * defaults, which means "auto-discover `tsconfig.json` and read its configured
91
- * plugins", the standard ttsc behaviour, and the right thing for a project that
92
- * called `withTtsc(config)` with no explicit options.
71
+ * Reads {@link ENV_KEY}; when it is unset or contains invalid JSON or a
72
+ * non-object payload, the adapter falls back to defaults: auto-discover
73
+ * `tsconfig.json` and read its configured plugins. This is the standard ttsc
74
+ * behaviour for a project that called `withTtsc(config)` with no explicit
75
+ * options.
76
+ *
77
+ * All common compiler options, including the output-addressing `projectRoot`,
78
+ * survive this transport without interpreting their paths in the config host.
79
+ * A present `plugins` property is forwarded even when its value is `false`;
80
+ * omission leaves plugin selection to the project. The `include` and `exclude`
81
+ * arrays retain only string entries, while invalid array values become empty
82
+ * filters. Only a non-empty string is accepted as the private run identity.
83
+ *
84
+ * @evidence contracts/common.md#principled-implementation
85
+ * JSON.parse reads the adapter-owned environment channel. The parser accepts
86
+ * only object payloads, filters include/exclude to strings and preserves
87
+ * plugins field presence so false differs from omission.
88
+ *
89
+ * @evidence contracts/common.md#clear-and-simple-design
90
+ * One reconstruction function separates transport parsing and string-array
91
+ * normalization from the resolved object workers consume.
92
+ *
93
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
94
+ * Invalid JSON and non-object payloads use documented defaults, not a failed
95
+ * compiler fallback; this parser patches no global APIs.
96
+ *
97
+ * @evidence contracts/common.md#meaningful-documentation
98
+ * The native JSDoc explains absent, invalid-JSON and non-object defaults,
99
+ * plugin-presence semantics and filter normalization. Checked against the
100
+ * documentation skill: separate paragraphs state the contract and why its
101
+ * nonobvious boundary matters; field comments retain their own useful
102
+ * facts.
103
+ *
104
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
105
+ * Returns a fresh object and keeps no state or handle.
106
+ *
107
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
108
+ * One JSON.parse and a filter over the short include and exclude arrays.
109
+ *
110
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
111
+ * The transformer calls it once per worker and memoises the result in options().
112
+ *
113
+ * @evidenceExclude contracts/portability.md#os-neutral-implementation
114
+ * Reads one environment variable as JSON and resolves no path.
93
115
  */
94
116
  export function resolveOptionsFromEnv(): ResolvedTtscMetroOptions {
95
117
  const raw = process.env[ENV_KEY];
@@ -97,6 +119,7 @@ export function resolveOptionsFromEnv(): ResolvedTtscMetroOptions {
97
119
  return {
98
120
  ttsc: {
99
121
  project: parsed.project,
122
+ projectRoot: parsed.projectRoot,
100
123
  compilerOptions: parsed.compilerOptions,
101
124
  ...("plugins" in parsed ? { plugins: parsed.plugins } : {}),
102
125
  },
@@ -0,0 +1,219 @@
1
+ import { createFilesystemPathIdentityContext } from "ttsc/path-identity";
2
+
3
+ /**
4
+ * Move every source location in an upstream transformer's AST from the ttsc
5
+ * transformed text back to the text the module's author wrote
6
+ * (samchon/ttsc#1392).
7
+ *
8
+ * Metro's Babel transformer contract returns an AST, never a map, and Metro
9
+ * builds the module's source map from that AST's `loc` positions against the
10
+ * file it read. The upstream transformer parsed the ttsc transformed text, so
11
+ * without this pass every position after the first change points at the
12
+ * reprinted lines: breakpoints, stack traces, and the error overlay would all
13
+ * be off. Each node's `loc` start and end are looked up in `map` with the usual
14
+ * greatest-lower-bound rule. A node whose start has no mapping into `file`,
15
+ * such as generated code, loses its `loc`, which Babel treats as a synthesized
16
+ * node, so it takes the mapping of the code around it.
17
+ *
18
+ * Babel locations have one-based lines and zero-based UTF-16 columns; source
19
+ * map segments have zero-based lines and columns. The map points from generated
20
+ * text to the authored file. Missing or reversed mapped ends clamp to the
21
+ * mapped start. Traversal tracks visited objects and mutates only AST
22
+ * locations. Absolute sources are compared by filesystem identity within this
23
+ * call, so links and actual directory case capabilities determine the owning
24
+ * source.
25
+ *
26
+ * @param ast The upstream transformer's AST, rewritten in place.
27
+ * @param map The adapter's map from the transformed text to `file`, with
28
+ * absolute `sources`.
29
+ * @param file Absolute path of the module.
30
+ * @evidence contracts/common.md#principled-implementation
31
+ * The adapter consumes its own source-map VLQ segments using
32
+ * greatest-lower-bound lookup, then updates the upstream-owned AST through
33
+ * Metro's returned-AST extension boundary. Iterative traversal tracks
34
+ * visited objects to handle shared/cyclic AST references. Unmapped starts
35
+ * lose loc and invalid ends clamp to the mapped start rather than
36
+ * manufacturing authored positions.
37
+ *
38
+ * @evidence contracts/common.md#clear-and-simple-design
39
+ * Mapping decoding, position lookup and object traversal remain distinct
40
+ * steps; the traversal owns AST mutation and the local lookup owns coordinate
41
+ * conversion without adding a second Babel parser.
42
+ *
43
+ * @evidence contracts/performance.md#efficient-algorithms
44
+ * Mapping decoding is linear in encoded bytes. A visited Set walks each
45
+ * AST object once; each endpoint uses binary search on its generated line's
46
+ * segments. Temporary storage follows decoded segments and reachable AST
47
+ * objects, and the iterative stack avoids call-depth dependence on the AST.
48
+ * Source identity resolution is shared within this call; missing sources can
49
+ * additionally require ancestor and directory-capability observations.
50
+ *
51
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
52
+ * This call mutates one returned AST using that delivery's map; it owns no
53
+ * shared cache and repeated calls would remap already moved coordinates.
54
+ *
55
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
56
+ * The caller owns the AST. Local decoded lines and traversal state end with
57
+ * the call; no handle or cross-delivery state is retained.
58
+ *
59
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
60
+ * No Babel methods or compiler internals are patched.
61
+ *
62
+ * @evidence contracts/portability.md#os-neutral-implementation
63
+ * The shared filesystem-identity context compares absolute map sources with
64
+ * the delivered file using physical spelling and actual directory case
65
+ * capabilities. Source coordinates remain line/column values independent
66
+ * of native newline spelling; OS names do not fold distinct filenames.
67
+ *
68
+ * @evidence contracts/common.md#meaningful-documentation
69
+ * The native JSDoc explains map direction, in-place mutation, coordinate
70
+ * indexing and generated/unmapped locations. Checked against the
71
+ * documentation skill: separate paragraphs state the contract and why its
72
+ * nonobvious boundary matters; field comments retain their own useful
73
+ * facts.
74
+ */
75
+ export function remapAstLocations(
76
+ ast: unknown,
77
+ map: { mappings: string; sources: readonly string[] },
78
+ file: string,
79
+ ): void {
80
+ const identities = createFilesystemPathIdentityContext({
81
+ throwOnRealpathError: false,
82
+ });
83
+ const fileKey = identities.resolve(file).key;
84
+ const own = map.sources.findIndex(
85
+ (source) => identities.resolve(source).key === fileKey,
86
+ );
87
+ if (own < 0) {
88
+ return;
89
+ }
90
+ const lines = decodeSegments(map.mappings);
91
+ const locate = (
92
+ position: unknown,
93
+ ): { column: number; line: number } | undefined => {
94
+ if (typeof position !== "object" || position === null) return undefined;
95
+ const { column, line } = position as { column?: unknown; line?: unknown };
96
+ if (typeof line !== "number" || typeof column !== "number") {
97
+ return undefined;
98
+ }
99
+ const segments = lines[line - 1];
100
+ if (segments === undefined) return undefined;
101
+ let low = 0;
102
+ let high = segments.length - 1;
103
+ let found: Segment | undefined;
104
+ while (low <= high) {
105
+ const middle = (low + high) >> 1;
106
+ const segment = segments[middle]!;
107
+ if (segment.generatedColumn <= column) {
108
+ found = segment;
109
+ low = middle + 1;
110
+ } else {
111
+ high = middle - 1;
112
+ }
113
+ }
114
+ return found === undefined || found.source !== own
115
+ ? undefined
116
+ : { column: found.column, line: found.line + 1 };
117
+ };
118
+
119
+ const visited = new Set<object>();
120
+ const pending: unknown[] = [ast];
121
+ while (pending.length !== 0) {
122
+ const value = pending.pop();
123
+ if (typeof value !== "object" || value === null || visited.has(value)) {
124
+ continue;
125
+ }
126
+ visited.add(value);
127
+ if (Array.isArray(value)) {
128
+ for (const entryToAppend of value) pending.push(entryToAppend);
129
+ continue;
130
+ }
131
+ const node = value as Record<string, unknown>;
132
+ for (const [key, child] of Object.entries(node)) {
133
+ if (key !== "loc") pending.push(child);
134
+ }
135
+ const loc = node.loc;
136
+ if (typeof loc !== "object" || loc === null) continue;
137
+ const { end, start } = loc as { end?: unknown; start?: unknown };
138
+ const mappedStart = locate(start);
139
+ if (mappedStart === undefined) {
140
+ node.loc = null;
141
+ continue;
142
+ }
143
+ const mappedEnd = locate(end);
144
+ node.loc = {
145
+ ...loc,
146
+ end:
147
+ mappedEnd === undefined ||
148
+ mappedEnd.line < mappedStart.line ||
149
+ (mappedEnd.line === mappedStart.line &&
150
+ mappedEnd.column < mappedStart.column)
151
+ ? mappedStart
152
+ : mappedEnd,
153
+ start: mappedStart,
154
+ };
155
+ }
156
+ }
157
+
158
+ /** One decoded mapping on a generated line. */
159
+ interface Segment {
160
+ /** Zero-based generated column. */
161
+ generatedColumn: number;
162
+
163
+ /** Index into the map's `sources`, or `-1` for an unmapped segment. */
164
+ source: number;
165
+
166
+ /** Zero-based source line. */
167
+ line: number;
168
+
169
+ /** Zero-based source column. */
170
+ column: number;
171
+ }
172
+
173
+ /**
174
+ * Decode `mappings` into its segments per generated line, in column order.
175
+ * Segments of every source are kept, and a segment with no source position is
176
+ * kept with source `-1`, so a lookup that lands on either reports no position
177
+ * instead of reaching past it to an earlier mapping.
178
+ */
179
+ function decodeSegments(mappings: string): Segment[][] {
180
+ const alphabet =
181
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
182
+ const lines: Segment[][] = [];
183
+ let source = 0;
184
+ let line = 0;
185
+ let column = 0;
186
+ for (const text of mappings.split(";")) {
187
+ const segments: Segment[] = [];
188
+ let generatedColumn = 0;
189
+ for (const encoded of text.split(",")) {
190
+ if (encoded === "") continue;
191
+ const fields: number[] = [];
192
+ let value = 0;
193
+ let shift = 0;
194
+ for (const char of encoded) {
195
+ const digit = alphabet.indexOf(char);
196
+ if (digit < 0) return lines;
197
+ value += (digit & 31) << shift;
198
+ if ((digit & 32) !== 0) {
199
+ shift += 5;
200
+ continue;
201
+ }
202
+ fields.push((value & 1) !== 0 ? -(value >>> 1) : value >>> 1);
203
+ value = 0;
204
+ shift = 0;
205
+ }
206
+ generatedColumn += fields[0] ?? 0;
207
+ if (fields.length < 4) {
208
+ segments.push({ column: 0, generatedColumn, line: 0, source: -1 });
209
+ continue;
210
+ }
211
+ source += fields[1]!;
212
+ line += fields[2]!;
213
+ column += fields[3]!;
214
+ segments.push({ column, generatedColumn, line, source });
215
+ }
216
+ lines.push(segments);
217
+ }
218
+ return lines;
219
+ }
@@ -1,25 +1,8 @@
1
1
  import { createRequire } from "node:module";
2
2
 
3
- const nodeRequire = createRequire(import.meta.url);
3
+ import type { UpstreamTransformer } from "./UpstreamTransformer";
4
4
 
5
- /**
6
- * The subset of the Metro Babel-transformer contract this adapter relies on.
7
- *
8
- * Metro loads the module named by `transformer.babelTransformerPath` and calls
9
- * its `transform` once per file, expecting a Babel AST back. `getCacheKey` is
10
- * an optional export Metro folds into its transform-cache key; Metro invokes it
11
- * with arguments (e.g. `{ projectRoot, enableBabelRCLookup }`), so it is typed
12
- * variadic.
13
- */
14
- export interface UpstreamTransformer {
15
- transform(params: {
16
- src: string;
17
- filename: string;
18
- options: Record<string, unknown>;
19
- [key: string]: unknown;
20
- }): Promise<{ ast: object }>;
21
- getCacheKey?: (...args: unknown[]) => string;
22
- }
5
+ const nodeRequire = createRequire(import.meta.url);
23
6
 
24
7
  /**
25
8
  * Upstream transformer module specifiers tried (in order) when no explicit
@@ -40,14 +23,55 @@ export const UPSTREAM_CANDIDATES = [
40
23
  * 1. An explicit `customPath` (the `upstreamTransformer` option);
41
24
  * 2. Each of {@link UPSTREAM_CANDIDATES} in turn.
42
25
  *
43
- * These are declared as optional peers and resolved at runtime against the
44
- * consumer project, so the adapter carries no Metro/Expo dependency itself.
45
- * Resolution is not memoised: Node's own module cache already makes the
46
- * repeated `require` a cheap lookup, and keeping no module-level state lets a
47
- * changed `upstreamTransformer` always take effect.
26
+ * `withTtsc` resolves an explicit specifier and the automatic candidates from
27
+ * the consuming project in the Metro config process, and publishes the absolute
28
+ * path it found (see {@link locateProjectUpstreamTransformer}). This worker-side
29
+ * lookup, rooted in the adapter's own location, is what remains for a specifier
30
+ * the project could not resolve, so the adapter carries no Metro/Expo
31
+ * dependency itself. Resolution is not memoised: Node's own module cache
32
+ * already makes the repeated `require` a cheap lookup, and keeping no
33
+ * module-level state lets a changed `upstreamTransformer` always take effect.
34
+ *
35
+ * `load` is the explicit module-loading boundary. Its default uses Node's real
36
+ * `require`; an injected loader must preserve absence versus failure
37
+ * semantics.
38
+ *
39
+ * @evidence contracts/common.md#principled-implementation
40
+ * Node createRequire loads the configured module or documented
41
+ * Expo/React-Native candidates. Resolution is separated from execution: only
42
+ * known entry-absence codes permit another automatic candidate;
43
+ * installed-module initialization failures preserve their cause.
48
44
  *
49
- * `load` is injectable purely so the resolution order and the not-found path
50
- * can be tested deterministically; production always uses the real `require`.
45
+ * @evidence contracts/common.md#clear-and-simple-design
46
+ * Explicit selection and ordered detection share the loading boundary.
47
+ * tryRequire owns absence classification, leaving this function to choose
48
+ * the transformer and attach contextual errors.
49
+ *
50
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
51
+ * The loader argument is an explicit dependency-injection boundary, not a
52
+ * test-only runtime branch or a patched require.
53
+ *
54
+ * @evidence contracts/portability.md#os-neutral-implementation
55
+ * Node resolution accepts project-resolved absolute paths on Windows and
56
+ * POSIX. createRequire is rooted at this module via import.meta.url; no
57
+ * path is turned into a shell command. Runtime errors propagate through the
58
+ * same policy on every OS.
59
+ *
60
+ * @evidence contracts/common.md#meaningful-documentation
61
+ * The native JSDoc explains candidate order, project versus worker
62
+ * resolution and absence versus initialization failures. Checked against
63
+ * the documentation skill: separate paragraphs state the contract and why
64
+ * its nonobvious boundary matters; field comments retain their own useful
65
+ * facts.
66
+ *
67
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
68
+ * Keeps no state between calls; loaded modules belong to Node's module cache.
69
+ *
70
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
71
+ * At most three candidate probes per call, each a require.resolve followed by one require.
72
+ *
73
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
74
+ * Deliberately unmemoised: Node's module cache makes a repeated require cheap and a changed option must take effect.
51
75
  */
52
76
  export function resolveUpstreamTransformer(
53
77
  customPath?: string,
@@ -101,6 +125,66 @@ export function resolveUpstreamTransformer(
101
125
  );
102
126
  }
103
127
 
128
+ /**
129
+ * Locate the automatic upstream transformer the consuming project installed,
130
+ * without executing it.
131
+ *
132
+ * The candidates belong to the app, not to this adapter: under pnpm or a linked
133
+ * workspace the adapter sits outside the app's `node_modules` ancestry, so a
134
+ * lookup rooted in the adapter cannot see a transformer only the app installed.
135
+ * `resolve` is rooted in the project; each candidate is tried in
136
+ * {@link UPSTREAM_CANDIDATES} order and an absent one is skipped, exactly as the
137
+ * worker's probe skips it. Only resolution runs here, so a broken installation
138
+ * still fails where the worker loads it, with its own error.
139
+ *
140
+ * @param resolve Resolves a module specifier from the project.
141
+ * @returns The absolute path of the first installed candidate, or `undefined`.
142
+ * @evidence contracts/common.md#principled-implementation
143
+ * The supplied project resolver uses Node module resolution without
144
+ * executing candidate code. Only recognized entry-absence errors continue
145
+ * probing; other resolver failures propagate.
146
+ *
147
+ * @evidence contracts/common.md#clear-and-simple-design
148
+ * Project discovery resolves only the shared candidate list; execution is
149
+ * deferred to the worker loader that owns initialization failures.
150
+ *
151
+ * @evidence contracts/common.md#prohibited-implementation-shortcuts
152
+ * The documented optional-peer order is a product default, not a
153
+ * fixture-specific answer. No module methods are replaced.
154
+ *
155
+ * @evidence contracts/portability.md#os-neutral-implementation
156
+ * The injected resolver owns project filesystem resolution on the host OS.
157
+ * This operation compares module specifiers and returns its absolute result
158
+ * without inventing separators or quoting a command.
159
+ *
160
+ * @evidence contracts/common.md#meaningful-documentation
161
+ * The native JSDoc explains project ownership, ordered probing,
162
+ * nonexecution and the absent result. Checked against the documentation
163
+ * skill: separate paragraphs state the contract and why its nonobvious
164
+ * boundary matters; field comments retain their own useful facts.
165
+ *
166
+ * @evidenceExclude contracts/performance.md#bound-retention-and-release-resources
167
+ * Resolves paths without executing a candidate and retains nothing.
168
+ *
169
+ * @evidenceExclude contracts/performance.md#efficient-algorithms
170
+ * At most three resolutions, stopping at the first one that succeeds.
171
+ *
172
+ * @evidenceExclude contracts/performance.md#reuse-equivalent-work
173
+ * Runs once per withTtsc call, in the config process only.
174
+ */
175
+ export function locateProjectUpstreamTransformer(
176
+ resolve: (specifier: string) => string,
177
+ ): string | undefined {
178
+ for (const candidate of UPSTREAM_CANDIDATES) {
179
+ try {
180
+ return resolve(candidate);
181
+ } catch (error) {
182
+ if (!isCandidateAbsent(error)) throw error;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+
104
188
  /**
105
189
  * Load an upstream transformer module, separating genuine absence from a broken
106
190
  * installation.