@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.6

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,68 @@
1
+ // TOML for the build descriptions the loaders take -- Cargo.toml, Cargo.lock, foundry.toml -- read
2
+ // by @preventive/lockfile's strict TOML 1.0 parser into a tree of tables, and refused where it
3
+ // isn't TOML, or isn't the TOML those files are written in: a bad escape, an unterminated string, a
4
+ // key or table defined twice, a local date, U+FFFD where a lenient decoder replaced bytes ... each a
5
+ // TomlError naming the file and line, never a guessed value.
6
+
7
+ import { TomlError, parseToml } from '@preventive/lockfile/toml.js'
8
+
9
+ export { TomlError }
10
+
11
+ // --- Bracket helpers (for the Rust scanner) ----------------------------------------------
12
+
13
+ // Index of the bracket closing the one opened at `open`, or the last index when unbalanced.
14
+ export function matchClose(text, open) {
15
+ const close = { '[': ']', '(': ')', '{': '}' }[text[open]]
16
+ let depth = 0
17
+ for (let i = open; i < text.length; i++) {
18
+ if (text[i] === text[open]) depth++
19
+ else if (text[i] === close && --depth === 0) return i
20
+ }
21
+ return text.length - 1
22
+ }
23
+
24
+ // Split on commas outside brackets/braces/parentheses/strings.
25
+ export function splitTopLevel(text) {
26
+ const parts = []
27
+ let depth = 0
28
+ let start = 0
29
+ let inStr = false
30
+ for (let i = 0; i < text.length; i++) {
31
+ const ch = text[i]
32
+ if (inStr) {
33
+ if (ch === '\\') i++
34
+ else if (ch === '"') inStr = false
35
+ continue
36
+ }
37
+ if (ch === '"') inStr = true
38
+ else if (ch === '(' || ch === '[' || ch === '{') depth++
39
+ else if (ch === ')' || ch === ']' || ch === '}') depth--
40
+ else if (ch === ',' && depth === 0) {
41
+ parts.push(text.slice(start, i))
42
+ start = i + 1
43
+ }
44
+ }
45
+ parts.push(text.slice(start))
46
+ return parts
47
+ }
48
+
49
+ // --- The reader -----------------------------------------------------------------------------
50
+
51
+ // The table tree of TOML `text` (parseToml): tables are null-prototype objects, arrays arrays, and
52
+ // values strings, booleans, numbers (an integer past 2^53 a BigInt), TomlFloat and TomlDateTime.
53
+ // Throws a TomlError on anything else, naming `file` when given.
54
+ export const readToml = (text, file = null) => nameErrors(file, () => parseToml(text))
55
+
56
+ // `read()`, with an error of one of `classes` it throws (@preventive/lockfile's, whose message
57
+ // leads with where it is) leading with `file` too, its fields kept; null `file` names none.
58
+ export function nameErrors(file, read, classes = [TomlError]) {
59
+ try {
60
+ return read()
61
+ } catch (err) {
62
+ if (file === null || !classes.some((c) => err instanceof c)) throw err
63
+ throw Object.assign(new err.constructor(`${file}: ${err.message}`), err)
64
+ }
65
+ }
66
+
67
+ // Whether a TOML value is a table (a TomlFloat or TomlDateTime is an object too).
68
+ export const isTomlTable = (value) => value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === null
@@ -1,9 +1,10 @@
1
- import { readFileSync, realpathSync, statSync } from 'node:fs'
2
1
  import { createRequire, isBuiltin } from 'node:module'
3
2
  import { dirname, isAbsolute, join } from 'node:path'
4
3
  import { pathToFileURL } from 'node:url'
5
4
 
5
+ import { diskHost } from '@exodus/stasis-core/host'
6
6
  import { isTypeDeclaration } from '@exodus/stasis-core/util'
7
+ import { readJson } from './resolve-typescript.js'
7
8
 
8
9
  // Adapter that drives the PROJECT's own `metro-resolver` from stasis's static scanner, so
9
10
  // `stasis bundle --metro --metro-resolver` resolves modules EXACTLY the way that project's
@@ -23,29 +24,10 @@ import { isTypeDeclaration } from '@exodus/stasis-core/util'
23
24
  // redirect via its own bundled helper, so the `redirectModulePath` we place on the context is
24
25
  // an inert placeholder that satisfies the type but is never consulted for base resolution.
25
26
 
26
- function readJson(file) {
27
- let text
28
- try {
29
- text = readFileSync(file, 'utf8')
30
- } catch {
31
- return null // absent / unreadable -- no package manifest here
32
- }
33
- // A manifest that EXISTS but is malformed must fail closed (Metro would throw, not resolve past it).
34
- try {
35
- return JSON.parse(text)
36
- } catch (cause) {
37
- throw new Error(`Invalid package.json: ${file}`, { cause })
38
- }
39
- }
40
-
41
27
  // 'f' | 'd' | null for a path, without throwing.
42
28
  function pathType(p) {
43
- try {
44
- const s = statSync(p)
45
- return s.isFile() ? 'f' : s.isDirectory() ? 'd' : null
46
- } catch {
47
- return null
48
- }
29
+ const s = diskHost.stat(p)
30
+ return s?.isFile() ? 'f' : s?.isDirectory() ? 'd' : null
49
31
  }
50
32
 
51
33
  // A `.d.ts` is types-only, erased at runtime, so the resolver must never land on one: report it as
@@ -82,12 +64,15 @@ function getPackageForModule(absModulePath) {
82
64
  export function createMetroResolver({
83
65
  projectDir,
84
66
  platform,
85
- sourceExts = ['js', 'json', 'ts'],
67
+ sourceExts = ['js', 'jsx', 'json', 'ts', 'tsx'],
86
68
  mainFields = ['react-native', 'browser', 'main'],
87
69
  conditionNames = ['react-native'],
88
70
  conditionsByPlatform = { web: ['browser'] },
89
71
  enablePackageExports = true,
72
+ host = diskHost,
90
73
  } = {}) {
74
+ // It loads the project's metro-resolver, which reads the disk: never off it.
75
+ if (host !== diskHost) throw new Error('createMetroResolver: metro-resolver reads the disk, so it is not supported off disk')
91
76
  const require = createRequire(join(projectDir, 'noop.js'))
92
77
  let resolve
93
78
  try {
@@ -118,7 +103,7 @@ export function createMetroResolver({
118
103
  if (!type) return { exists: false }
119
104
  let realPath = abs
120
105
  try {
121
- realPath = realpathSync(abs)
106
+ realPath = diskHost.realpath(abs)
122
107
  } catch { /* keep the lexical path when realpath fails (e.g. a broken symlink) */ }
123
108
  return { exists: true, type, realPath }
124
109
  },
package/src/mock.js CHANGED
@@ -184,13 +184,11 @@ process.on('exit', () => {
184
184
  // exits cleanly and the loader's beforeExit hook writes the bundle.
185
185
  const noop = () => undefined
186
186
  const pending = () => new Promise(() => {})
187
- for (const name of ['setTimeout', 'setInterval', 'setImmediate']) globalThis[name] = noop
188
- for (const name of ['clearTimeout', 'clearInterval', 'clearImmediate']) globalThis[name] = noop
187
+ const TIMERS = ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval', 'clearImmediate']
188
+ for (const name of TIMERS) globalThis[name] = noop
189
189
 
190
190
  const timers = require('node:timers')
191
- for (const name of ['setTimeout', 'setInterval', 'setImmediate', 'clearTimeout', 'clearInterval', 'clearImmediate']) {
192
- mock.method(timers, name, noop)
193
- }
191
+ for (const name of TIMERS) mock.method(timers, name, noop)
194
192
 
195
193
  const timersPromises = require('node:timers/promises')
196
194
  mock.method(timersPromises, 'setTimeout', pending)
@@ -0,0 +1,338 @@
1
+ import { isBuiltin } from 'node:module'
2
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { isTypeDeclaration, stripTypeDeclaration, toPosix } from '@exodus/stasis-core/util'
6
+ import {
7
+ inNodeModules,
8
+ isDir,
9
+ isFile,
10
+ locatePackage,
11
+ nearestPackage,
12
+ readJson,
13
+ resolveTypescriptFallback,
14
+ typescriptSiblings,
15
+ } from './resolve-typescript.js'
16
+ import { diskHost } from '@exodus/stasis-core/host'
17
+
18
+ // Static module resolver for legacy package fields (`react-native`/`browser`/`main` + browser-spec
19
+ // redirect maps) and platform suffixes (`.ios`/`.android`/`.native`), reproducing Metro/React-Native
20
+ // resolution that Node's own resolver (used by scan.js) can't. `exports`-bearing packages are
21
+ // delegated to Node. No user code is executed (only package.json is stat/read).
22
+ //
23
+ // Returns, for each specifier:
24
+ // { url } resolved to a real file (file: URL string)
25
+ // { empty } a browser/react-native field mapped it to `false` (empty module)
26
+ // { builtin } a Node builtin
27
+ // null unresolved
28
+
29
+ // Metro divergence toggle. When a package's browser map maps its OWN entry to `false` (under
30
+ // Metro's matching rules, which include bare keys like {"buf": false} for main "./buf.js"),
31
+ // Metro's `getPackageEntryPoint` ignores the non-string replacement and keeps `main`. The
32
+ // `--metro` resolver matches that by default; flip this to `false` to fail closed instead:
33
+ // a `false` match on the entry then yields an EMPTY module. Note the off state is deliberately
34
+ // stricter than both tools -- for a bare-key `false`, Metro keeps `main` and esbuild would not
35
+ // match the entry at all -- it exists as an escape hatch, not an esbuild-parity mode. This only
36
+ // affects the `--metro` path; the `--mainFields` path always empties (matching esbuild).
37
+ // Tests can override per-resolver via createFieldResolver's `metroKeepEntryOnBrowserFalse`.
38
+ const METRO_KEEP_ENTRY_ON_BROWSER_FALSE = true
39
+
40
+ // First mainFields entry that's a non-empty string, else `index` (object-valued fields are redirect maps).
41
+ function packageEntry(pkg, mainFields) {
42
+ for (const name of mainFields) {
43
+ if (typeof pkg?.[name] === 'string' && pkg[name].length > 0) return pkg[name]
44
+ }
45
+ return 'index'
46
+ }
47
+
48
+ // Merge object-valued mainFields (browser-spec maps) into one redirect map. Iterate in REVERSE
49
+ // so earlier mainFields win on conflict. Keys are verbatim: `./x` relative vs bare `x` (kept bare).
50
+ function mergeRedirectMap(pkg, mainFields) {
51
+ const map = new Map()
52
+ for (let i = mainFields.length - 1; i >= 0; i--) {
53
+ const field = pkg?.[mainFields[i]]
54
+ if (!field || typeof field !== 'object') continue
55
+ for (const [key, value] of Object.entries(field)) {
56
+ if (typeof value !== 'string' && value !== false) continue
57
+ map.set(key, value)
58
+ }
59
+ }
60
+ return map
61
+ }
62
+
63
+ // Metro's entry-variant list for `main` (getPackageEntryPoint, metro-resolver
64
+ // PackageResolve.js): the main spelling and its ./-toggled twin, each expanded to
65
+ // [self, +'.js', +'.json', ext-stripped]. ORDER MATTERS -- the first variant with ANY mapping
66
+ // (string or false) decides, so this must reproduce Metro's list exactly, duplicates included.
67
+ function metroEntryVariants(main) {
68
+ const toggled = main.startsWith('./') ? main.slice(2) : `./${main}`
69
+ return [main, toggled].flatMap((v) => [v, `${v}.js`, `${v}.json`, v.replace(/\.(?:js|json)$/u, '')])
70
+ }
71
+
72
+ // Resolve the package ENTRY (from mainFields) through the package's own redirect map.
73
+ // Returns { entry } (the redirected or original entry to resolve) or { empty: true }.
74
+ // Both halves of the entry semantics -- WHICH keys match, and what `false` means -- live here
75
+ // so no caller can get one without the other:
76
+ // - metro: Metro's getPackageEntryPoint. First matching variant (Metro's order) wins; a string
77
+ // redirects; `false` (any non-string) keeps `main`, unless the keep-on-false toggle is off,
78
+ // in which case it fails closed to an empty module (see METRO_KEEP_ENTRY_ON_BROWSER_FALSE).
79
+ // - default (--mainFields): esbuild-shaped candidates; `false` empties the entry, matching
80
+ // esbuild. The extension-STRIPPED bare spelling is not probed: for an extensioned main,
81
+ // esbuild treats it as a bare module name, not the entry. (For an extensionless main the
82
+ // `bare` candidate covers it -- esbuild does match that spelling; verified empirically.)
83
+ function resolveEntryThroughMap(map, main, opts) {
84
+ if (map.size === 0) return { entry: main }
85
+ let cands
86
+ if (opts.metro) {
87
+ cands = metroEntryVariants(main)
88
+ } else {
89
+ const bare = main.replace(/^\.\//u, '')
90
+ const stripped = bare.replace(/\.(?:js|json)$/u, '')
91
+ cands = [
92
+ `./${bare}`, bare,
93
+ `./${stripped}`,
94
+ `./${stripped}.js`, `${stripped}.js`,
95
+ `./${stripped}.json`, `${stripped}.json`,
96
+ ]
97
+ }
98
+ for (const cand of cands) {
99
+ const v = map.get(cand)
100
+ if (typeof v === 'string') return { entry: v }
101
+ if (v === false) {
102
+ if (opts.metro && opts.metroKeepEntryOnBrowserFalse) return { entry: main }
103
+ return { empty: true }
104
+ }
105
+ }
106
+ return { entry: main }
107
+ }
108
+
109
+ const fileResolution = (path) => ({ url: pathToFileURL(path).toString() })
110
+
111
+ // Match a package-relative path (`./x`) against the redirect map, trying `.js`/`.json` appended.
112
+ function matchRedirect(map, relPath) {
113
+ if (map.size === 0) return undefined
114
+ for (const cand of [relPath, `${relPath}.js`, `${relPath}.json`]) {
115
+ if (map.has(cand)) return map.get(cand)
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ // Probe `base` for a source file in platform order: bare name first, then per ext
121
+ // `base.<platform>.<ext>`, `base.native.<ext>` (native only), `base.<ext>`. `platform: null`
122
+ // disables suffixes. Under metro, every SUFFIXED candidate first passes through the candidate's
123
+ // own package redirect map -- mirroring Metro's resolveSourceFileForExt, which runs
124
+ // redirectModulePath on each non-bare candidate: `false` short-circuits the whole probe to an
125
+ // empty module (e.g. browser { "./index.ios.js": false }), and a string redirect re-lands the
126
+ // candidate package-root-relative. Returns a path, { empty: true } (metro only), or null.
127
+ function resolveSourceFile(base, opts) {
128
+ // A path that literally names a `.d.ts` names no runtime module (types are erased). Probe its
129
+ // declaration-free stem instead, so an entry field pointing at `dist/index.d.ts` lands on the real
130
+ // `dist/index.js` beside it rather than bundling the declaration -- or failing the package
131
+ // outright by falling back to a different index. The probe below still refuses a literal hit.
132
+ base = stripTypeDeclaration(base)
133
+ // Candidates share base's directory, hence its package scope; resolve it lazily, once.
134
+ let scope
135
+ const redirectCandidate = (p) => {
136
+ if (scope === undefined) {
137
+ const pkg = nearestPackage(base, opts.host)
138
+ scope = pkg ? { pkgDir: pkg.pkgDir, map: mergeRedirectMap(pkg.pkg, opts.mainFields) } : null
139
+ }
140
+ if (!scope || scope.map.size === 0) return p
141
+ const rel = `./${toPosix(relative(scope.pkgDir, p))}`
142
+ const r = matchRedirect(scope.map, rel)
143
+ if (r === false) return false
144
+ if (typeof r === 'string') return isAbsolute(r) ? r : resolvePath(scope.pkgDir, r)
145
+ return p
146
+ }
147
+ // `derived` marks a candidate that is not the literal base; Metro redirect-checks those only.
148
+ const probePath = (p, derived) => {
149
+ if (opts.metro && derived) {
150
+ const r = redirectCandidate(p)
151
+ if (r === false) return { empty: true }
152
+ p = r
153
+ }
154
+ // A `.d.ts` is types-only, erased at runtime: never a resolution target, so a package whose
155
+ // entry field points at one falls through to its real `.js` instead of bundling the declaration.
156
+ if (isTypeDeclaration(p)) return null
157
+ return isFile(p, opts.host) ? p : null
158
+ }
159
+ // `suffix` is everything appended to `base`.
160
+ const probe = (suffix) => probePath(`${base}${suffix}`, suffix !== '')
161
+ const tryExt = (sourceExt) => {
162
+ if (opts.platform) {
163
+ const hit = probe(`.${opts.platform}${sourceExt}`)
164
+ if (hit) return hit
165
+ }
166
+ if (opts.preferNative && sourceExt !== '') {
167
+ const hit = probe(`.native${sourceExt}`)
168
+ if (hit) return hit
169
+ }
170
+ return probe(sourceExt)
171
+ }
172
+ const bare = tryExt('')
173
+ if (bare) return bare
174
+ // --typescript: tsc's extension substitution. A base naming a JS output extension probes its TS
175
+ // source siblings once the literal file (the bare probe above) is absent, so the on-disk `.js`
176
+ // always wins over its `.ts` twin. Literal siblings only -- no platform suffixes (tsc has none);
177
+ // extensionless bases keep going through the appended-extension loop below (sourceExts carries
178
+ // ts and tsx). Placed before that loop so `x.js` -> `x.ts` beats a pathological
179
+ // `x.js.<ext>`, matching tsc's candidate order. Never into node_modules: where tsc's pick, the
180
+ // first sibling on disk, lies there by its real path, nothing maps (inNodeModules), as the shared
181
+ // fallback refuses its own -- never a later sibling tsc wouldn't pick. The resolver drops
182
+ // `typescript` for an importer there.
183
+ if (opts.typescript) {
184
+ for (const cand of typescriptSiblings(base)) {
185
+ const hit = probePath(cand, true)
186
+ if (hit === null) continue
187
+ return typeof hit === 'string' && inNodeModules(hit, opts.host) ? null : hit
188
+ }
189
+ }
190
+ for (const ext of opts.sourceExts) {
191
+ const hit = tryExt(`.${ext}`)
192
+ if (hit) return hit
193
+ }
194
+ return null
195
+ }
196
+
197
+ // Resolve absolute `base` as a file (ext probing) else as a directory (package.json `main` via
198
+ // mainFields + entry redirect, else `index`). `exports` is NOT consulted — Node applies it only to
199
+ // bare package-name imports, not to a path landing on a directory.
200
+ function resolveFileOrDir(base, opts) {
201
+ // resolveSourceFile yields a path, { empty: true } (a metro candidate-redirect hit), or null.
202
+ const asResolution = (hit) => (hit == null ? null : typeof hit === 'string' ? fileResolution(hit) : hit)
203
+ const file = asResolution(resolveSourceFile(base, opts))
204
+ if (file) return file
205
+ if (isDir(base, opts.host)) {
206
+ const dpkg = readJson(join(base, 'package.json'), opts.host)
207
+ if (dpkg) {
208
+ const r = resolveEntryThroughMap(mergeRedirectMap(dpkg, opts.mainFields), packageEntry(dpkg, opts.mainFields), opts)
209
+ if (r.empty) return { empty: true }
210
+ // Node's LOAD_AS_DIRECTORY resolves `main` as a file, else as a directory index
211
+ // (`main: "./lib/"` -> lib/index.js); a broken `main` falls back to the package index below.
212
+ const entryBase = join(base, r.entry)
213
+ const inner = asResolution(resolveSourceFile(entryBase, opts) ?? resolveSourceFile(join(entryBase, 'index'), opts))
214
+ if (inner) return inner
215
+ }
216
+ const index = asResolution(resolveSourceFile(join(base, 'index'), opts))
217
+ if (index) return index
218
+ }
219
+ return null
220
+ }
221
+
222
+ // Build a resolver bound to options: `conditions` gates `exports` packages (delegated to Node),
223
+ // `mainFields`/`platform`/`preferNative`/`sourceExts` drive the legacy-field + suffix probing.
224
+ // `metro` opts into Metro's package-entry + candidate-redirect semantics (see
225
+ // resolveEntryThroughMap and resolveSourceFile); leave it off for the esbuild-parity
226
+ // `--mainFields` path. `typescript` adds tsc's extension substitution (a missing `x.js` probes
227
+ // its `x.ts` sibling; see resolveSourceFile) plus the shared miss fallback (see below), and
228
+ // `typescriptPaths` (a tsconfig paths matcher; see resolveTypescriptFallback) its tsconfig alias mapping.
229
+ // `metroKeepEntryOnBrowserFalse` overrides the module-level toggle
230
+ // (METRO_KEEP_ENTRY_ON_BROWSER_FALSE) per resolver -- primarily so tests can cover both branches.
231
+ // `host` is the filesystem view (@exodus/stasis-core/host), the real disk by default.
232
+ export function createFieldResolver({
233
+ conditions = [],
234
+ mainFields = ['main'],
235
+ platform = null,
236
+ preferNative = false,
237
+ sourceExts = ['js', 'jsx', 'json', 'ts', 'tsx'],
238
+ metro = false,
239
+ typescript = false,
240
+ typescriptPaths = null,
241
+ metroKeepEntryOnBrowserFalse = METRO_KEEP_ENTRY_ON_BROWSER_FALSE,
242
+ host = diskHost,
243
+ } = {}) {
244
+ const opts = { platform, preferNative, sourceExts, mainFields, metro, typescript, metroKeepEntryOnBrowserFalse, host }
245
+ // An importer in node_modules gets no --typescript mapping (inNodeModules), wherever its import lands.
246
+ const fromNodeModules = { ...opts, typescript: false }
247
+ // `callConditions` (from scan) is the parent's format-driven condition set, so `exports`
248
+ // delegation matches Node resolving from THAT file; falls back to configured `conditions`.
249
+ const resolve = function resolve(parentFile, specifier, callConditions) {
250
+ const fileOpts = typescript && inNodeModules(parentFile, host) ? fromNodeModules : opts
251
+ const conds = new Set(callConditions ?? conditions)
252
+ const viaNode = (spec) => {
253
+ try {
254
+ return fileResolution(host.resolve(parentFile, spec, conds))
255
+ } catch {
256
+ return null
257
+ }
258
+ }
259
+ // `#name` subpath imports use the `imports` field + conditions; Node's algorithm handles them.
260
+ if (specifier.startsWith('#')) return viaNode(specifier)
261
+
262
+ let spec = specifier
263
+ // Redirect via the IMPORTER's browser/react-native map, BEFORE the builtin check on purpose:
264
+ // a map entry can disable or shim a builtin (`{"crypto": false}` / `"crypto-browserify"`).
265
+ const imp = nearestPackage(parentFile, host)
266
+ if (imp) {
267
+ const map = mergeRedirectMap(imp.pkg, mainFields)
268
+ let r
269
+ if (spec.startsWith('.') || isAbsolute(spec)) {
270
+ const abs = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
271
+ r = matchRedirect(map, `./${toPosix(relative(imp.pkgDir, abs))}`)
272
+ } else {
273
+ r = map.get(spec) // a bare specifier (`mod`, `mod/sub`) matches the map's bare keys
274
+ }
275
+ if (r === false) return { empty: true }
276
+ if (typeof r === 'string') {
277
+ // Browser-map targets are relative to the PACKAGE ROOT, not the importing file; a
278
+ // bare-module target re-enters resolution below.
279
+ if (r.startsWith('.') || isAbsolute(r)) {
280
+ return resolveFileOrDir(isAbsolute(r) ? r : resolvePath(imp.pkgDir, r), fileOpts)
281
+ }
282
+ spec = r
283
+ }
284
+ }
285
+
286
+ // A builtin the browser map did not remap resolves to the builtin itself.
287
+ if (isBuiltin(spec)) return { builtin: true }
288
+
289
+ if (spec.startsWith('.') || isAbsolute(spec)) {
290
+ const base = isAbsolute(spec) ? spec : resolvePath(dirname(parentFile), spec)
291
+ return resolveFileOrDir(base, fileOpts)
292
+ }
293
+
294
+ const loc = locatePackage(dirname(parentFile), spec, host)
295
+ if (!loc) return null
296
+ const pkg = readJson(join(loc.pkgDir, 'package.json'), host) ?? {}
297
+ // `exports` wins over mainFields; Node's algorithm resolves it (with conditions) correctly.
298
+ if (pkg.exports != null) return viaNode(spec)
299
+ // A bare package import resolves its directory via the same dir algorithm as any other.
300
+ if (loc.subpath === '') return resolveFileOrDir(loc.pkgDir, fileOpts)
301
+ const sub = `./${loc.subpath}`
302
+ const r = matchRedirect(mergeRedirectMap(pkg, mainFields), sub)
303
+ if (r === false) return { empty: true }
304
+ const target = typeof r === 'string' ? r : sub
305
+ return resolveFileOrDir(join(loc.pkgDir, target), fileOpts)
306
+ }
307
+ if (!typescript) return resolve
308
+ // --typescript: when the whole field flow leaves the specifier unresolved, give tsc's mapping
309
+ // the same shot the scanner's fallback gets, via the shared dispatcher. On this path it covers
310
+ // the layers the field resolver delegates to Node -- `exports`-bearing packages and `#` subpath
311
+ // imports, whose targets may name compiled files existing only as TS source -- plus tsconfig
312
+ // `paths` aliases; misses only, so no field/redirect/suffix resolution is ever overridden.
313
+ // (Relative paths re-probe tsc-style too -- redundant after resolveSourceFile, but harmless.)
314
+ return function resolveWithTypescriptFallback(parentFile, specifier, callConditions) {
315
+ const resolved = resolve(parentFile, specifier, callConditions)
316
+ if (resolved) return resolved
317
+ const hit = resolveTypescriptFallback(parentFile, specifier, {
318
+ conditions: new Set(callConditions ?? conditions),
319
+ paths: typescriptPaths,
320
+ host,
321
+ })
322
+ return hit == null ? null : fileResolution(hit)
323
+ }
324
+ }
325
+
326
+ // True when `format` is an ESM family (drives the base condition set, mirroring scan).
327
+ function isModuleFormat(format) {
328
+ return format === 'module' || format === 'module-typescript'
329
+ }
330
+
331
+ // Condition set for a parent of the given format: Node's format-driven base (matching scan's
332
+ // #conditionsFor) plus extras. Order is irrelevant (Node membership-tests against exports key order).
333
+ export function resolveConditions(format, extras = []) {
334
+ const base = isModuleFormat(format)
335
+ ? ['node', 'import', 'module-sync', 'node-addons']
336
+ : ['node', 'require', 'module-sync', 'node-addons']
337
+ return [...new Set([...base, ...extras])]
338
+ }