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

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.
@@ -1,17 +1,7 @@
1
- // Rust loader: produces a `{ sources, resolutions, missing }` triple from a
2
- // crate's source files, mirroring the Solidity/Bash loaders' interface. The
1
+ // Rust loader: `{ sources, resolutions, missing }` from a crate's files. The
3
2
  // reachable set is discovered by following `mod foo;` declarations (the only
4
- // thing that pulls a new file into a crate); `use crate::path` statements
5
- // don't add files, they just reference already-declared modules, so they're
6
- // resolved against the module tree for the import graph but never widen the walk.
7
- //
8
- // `use crate::` resolution is best-effort (external crates, macro paths, and
9
- // item-vs-module ambiguity are simply dropped). An unconditional `mod foo;`
10
- // with no backing file is a genuine hole (a compile error in a real crate) and
11
- // is reported in `missing` so the bundle builder can reject it; a cfg-gated
12
- // `mod` (e.g. `#[cfg(test)] mod tests;`) may be compiled out and is tolerated.
13
- // Comments are stripped before extraction so a commented-out `mod`/`use` is
14
- // neither followed nor flagged.
3
+ // thing that pulls a new file in); `use crate::` edges resolve against the
4
+ // module tree for the import graph but never widen the walk.
15
5
 
16
6
  import { realpathSync, statSync } from 'node:fs'
17
7
  import { readFile } from 'node:fs/promises'
@@ -19,48 +9,33 @@ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path
19
9
 
20
10
  import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
21
11
 
22
- // `mod foo;` / `pub mod foo;` / `pub(crate) mod foo;` — external module
23
- // declarations only. Inline `mod foo { ... }` is intentionally NOT matched.
24
- // Applied per (trimmed) line, so it stays anchored at the line start.
12
+ // External `mod foo;` declarations only; inline `mod foo { ... }` is intentionally not matched.
25
13
  const MOD_DECL_RE = /^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/u
26
14
 
27
- // `crate::path::to::item` references (in `use` or expression position).
28
15
  const USE_CRATE_RE = /\bcrate(?:::\w+)+/gu
29
16
 
30
- // `cargo vendor` copies external crates in-tree under this directory; their
31
- // sources are pulled into the bundle and tagged `cargo` (see cmd/bundle.js).
17
+ // `cargo vendor` copies external crates in-tree under this dir.
32
18
  const VENDOR_DIR = 'vendor'
33
19
 
34
- // `extern crate foo;` and the leading segment of any `foo::path` use/expr path.
35
- // The leading segment of a path that ISN'T crate/self/super or a std-family
36
- // root is a candidate external crate name; resolveVendoredCrate keeps only the
37
- // ones that actually exist under vendor/. The lead must start lowercase to skip
38
- // `Type::assoc` associated-item paths (crate names are snake_case by convention).
20
+ // `extern crate foo;` and the leading segment of any `foo::path`. The lead must
21
+ // start lowercase (`[a-z_]`) to skip `Type::assoc` associated-item paths.
39
22
  const EXTERN_CRATE_RE = /^(?:pub\s+)?extern\s+crate\s+(\w+)\s*;/u
40
23
  const PATH_LEAD_RE = /\b([a-z_]\w*)(?:::\w+)+/gu
41
24
  const NON_CRATE_LEADS = new Set(['crate', 'self', 'super', 'std', 'core', 'alloc'])
42
25
 
43
- // A `#[cfg(...)]` / `#[cfg_attr(...)]` attribute gates the next item on a build
44
- // configuration that may be off (e.g. `#[cfg(test)]`).
26
+ // `#[cfg(...)]`/`#[cfg_attr(...)]` gates the next item on a config that may be off.
45
27
  const CFG_ATTR_RE = /^#\[\s*cfg(?:_attr)?\b/u
46
28
 
47
- // Strip `//` line comments and `/* */` block comments so a commented-out
48
- // `mod`/`use` isn't mistaken for a real one — a commented `mod foo;` with no
49
- // backing file would otherwise be reported as a fatal "missing module".
50
- // Newlines inside block comments are preserved so the per-line matching below
51
- // stays aligned. Not string-literal aware (a `//` or `/* */` inside a string
52
- // can't masquerade as a line-anchored `mod` decl, so it doesn't matter for
53
- // extraction), and nested block comments aren't handled — both are acceptable
54
- // for best-effort extraction and match the Solidity loader's fidelity.
29
+ // Strip comments so a commented-out `mod`/`use` isn't taken for a real one (a
30
+ // commented `mod foo;` with no file would be a false "missing module").
31
+ // Newlines in block comments are preserved to keep the per-line matching aligned.
55
32
  function stripRustComments(content) {
56
33
  const noBlock = content.replace(/\/\*[\s\S]*?\*\//gu, (m) => m.replace(/[^\n]/gu, ' '))
57
34
  return noBlock.replace(/\/\/[^\n]*/gu, '')
58
35
  }
59
36
 
60
- // Extract external `mod <name>;` declarations as { name, conditional } records.
61
- // `conditional` is true when a `#[cfg(...)]` attribute gates the declaration:
62
- // such a module may be compiled out (e.g. `#[cfg(test)] mod tests;`), so the
63
- // bundle builder treats its absence as tolerable rather than fatal.
37
+ // Extract external `mod <name>;` as { name, conditional } records; `conditional`
38
+ // (a `#[cfg(...)]`-gated decl) may be compiled out, so its absence is tolerable.
64
39
  export function extractModDecls(content) {
65
40
  const decls = []
66
41
  let cfgPending = false
@@ -84,10 +59,8 @@ export function extractCrateUses(content) {
84
59
  return [...stripRustComments(content).matchAll(USE_CRATE_RE)].map((m) => m[0])
85
60
  }
86
61
 
87
- // Candidate external-crate names referenced by a file: `extern crate <name>;`
88
- // plus the leading segment of every `<name>::...` path (minus crate/self/super
89
- // and the std family). Best-effort and deliberately over-inclusive — a lead
90
- // that doesn't name a real vendored crate is dropped by resolveVendoredCrate.
62
+ // Candidate external-crate names (best-effort, over-inclusive — bad leads are
63
+ // dropped later by resolveVendoredCrate).
91
64
  export function extractExternalCrates(content) {
92
65
  const stripped = stripRustComments(content)
93
66
  const names = new Set()
@@ -101,12 +74,8 @@ export function extractExternalCrates(content) {
101
74
  return [...names]
102
75
  }
103
76
 
104
- // Resolve an external crate name to its vendored crate-root file
105
- // (`vendor/<dir>/src/lib.rs`), or null when no such crate is vendored. A Rust
106
- // `use` name is the crate's lib name (underscores); the on-disk vendor dir uses
107
- // the package name, which may hyphenate — so try the name as-is and with
108
- // underscores swapped for hyphens. Existence is checked against `knownSources`
109
- // (tree mode) or the filesystem under `baseDir` (walk mode).
77
+ // Resolve a crate name to its vendored root (`vendor/<dir>/src/lib.rs`), or null.
78
+ // `use` names underscore but the vendor dir may hyphenate, so try both spellings.
110
79
  export function resolveVendoredCrate(crateName, { knownSources, baseDir, vendorDir = VENDOR_DIR } = {}) {
111
80
  const dirs = crateName.includes('_') ? [crateName, crateName.replaceAll('_', '-')] : [crateName]
112
81
  for (const dir of dirs) {
@@ -122,9 +91,8 @@ export function resolveVendoredCrate(crateName, { knownSources, baseDir, vendorD
122
91
  return null
123
92
  }
124
93
 
125
- // The directory where this file's submodules live, per Rust's path rules:
126
- // - crate roots (main.rs/lib.rs) and mod.rs hold submodules as siblings
127
- // - any other foo.rs holds them under a foo/ subdirectory
94
+ // Directory holding this file's submodules, per Rust's path rules: siblings for
95
+ // crate roots (main.rs/lib.rs) and mod.rs, else under a `<stem>/` subdir.
128
96
  export function getModuleDir(filePath) {
129
97
  const lastSlash = filePath.lastIndexOf('/')
130
98
  const dir = lastSlash === -1 ? '' : filePath.slice(0, lastSlash)
@@ -134,10 +102,7 @@ export function getModuleDir(filePath) {
134
102
  return dir ? `${dir}/${stem}` : stem
135
103
  }
136
104
 
137
- // Resolve a `mod <name>;` declaration to the baseDir-relative file that
138
- // defines it, trying `<dir>/<name>.rs` then `<dir>/<name>/mod.rs`. Existence
139
- // is checked against `knownSources` (tree mode) or the filesystem under
140
- // `baseDir` (walk mode); returns null when neither finds a file.
105
+ // Resolve `mod <name>;` to its file, trying `<dir>/<name>.rs` then `<dir>/<name>/mod.rs`.
141
106
  export function resolveModPath(modName, fromFile, { knownSources, baseDir } = {}) {
142
107
  const modDir = getModuleDir(fromFile)
143
108
  const base = modDir ? `${modDir}/${modName}` : modName
@@ -154,22 +119,14 @@ export function resolveModPath(modName, fromFile, { knownSources, baseDir } = {}
154
119
  return null
155
120
  }
156
121
 
157
- // Build a `module path -> file` map (e.g. "crate::foo::bar" -> "src/foo/bar.rs")
158
- // by walking `mod` edges out from every crate root (main.rs / lib.rs). The walk
159
- // is iterative (a deep `mod` chain would overflow a recursive descent) and
160
- // depth-bounded: a `seen` set stops mod cycles, and MAX_MODULE_DEPTH caps a
161
- // pathologically deep chain — both the descent and the O(depth²) growth of the
162
- // joined module-path strings. Real crates nest a handful deep; the cap only
163
- // ever trims best-effort `use crate::` resolution for absurd input (file
164
- // collection happens in collectRustFilesFromDisk, which is unaffected).
122
+ // Cap on `mod`-chain depth when building the module tree: with the `seen` set it
123
+ // bounds cycles and the O(depth²) growth of joined module-path strings on absurd input.
165
124
  const MAX_MODULE_DEPTH = 1000
166
125
 
167
126
  export function buildModuleTree(sources, resolutions, { vendorDir = null } = {}) {
168
127
  const tree = new Map()
169
128
  for (const filePath of sources.keys()) {
170
- // A vendored crate is its own crate, not part of the project crate rooted
171
- // at `crate::` — skip its root so its tree doesn't collide on the `crate`
172
- // key (its internal `crate::` edges are left best-effort/unresolved).
129
+ // Skip vendored crate roots: they're separate crates and would collide on the `crate` key.
173
130
  if (vendorDir && filePath.startsWith(`${vendorDir}/`)) continue
174
131
  const name = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath
175
132
  if (name !== 'main.rs' && name !== 'lib.rs') continue
@@ -191,9 +148,8 @@ export function buildModuleTree(sources, resolutions, { vendorDir = null } = {})
191
148
  return tree
192
149
  }
193
150
 
194
- // Resolve a `crate::a::b::Item` path to the file of the deepest module along
195
- // it (the trailing item names aren't modules), e.g. `crate::foo::Bar` ->
196
- // the file for `crate::foo`. Returns null when no prefix names a module.
151
+ // Resolve `crate::a::b::Item` to the deepest module prefix that names a file
152
+ // (trailing item names aren't modules); null when none does.
197
153
  export function resolveUsePath(usePath, moduleTree) {
198
154
  const parts = usePath.split('::')
199
155
  for (let i = parts.length; i >= 1; i--) {
@@ -203,12 +159,9 @@ export function resolveUsePath(usePath, moduleTree) {
203
159
  return null
204
160
  }
205
161
 
206
- // Build the { sources, resolutions, missing } triple from a Map of
207
- // already-loaded Rust sources. Two-phase: resolve `mod` edges (which also
208
- // defines the module tree), then resolve `crate::` references against it.
209
- // An unconditional `mod foo;` with no file is recorded in `missing` (the
210
- // bundle builder treats it as fatal); a cfg-gated `mod`, unresolved `crate::`
211
- // references, and self/duplicate references are omitted.
162
+ // Build the triple from already-loaded sources. Two-phase: resolve `mod` edges
163
+ // (defining the module tree), then `crate::` refs against it. Unconditional
164
+ // `mod` with no file → `missing` (fatal); cfg-gated/unresolved/self refs omitted.
212
165
  export function buildRustTree(sources) {
213
166
  const resolutions = new Map()
214
167
  const missing = []
@@ -222,17 +175,15 @@ export function buildRustTree(sources) {
222
175
  console.warn(`[loader.rust] Missing module: ${name} from ${path}`)
223
176
  missing.push({ spec: `mod ${name}`, from: path })
224
177
  }
225
- // conditional && unresolved: a cfg-gated module that may be compiled out
226
- // (e.g. #[cfg(test)] mod tests;) — tolerated, not a fatal hole.
178
+ // conditional && unresolved: cfg-gated module, may be compiled out — tolerated.
227
179
  }
228
180
  resolutions.set(path, specMap)
229
181
  }
230
182
 
231
183
  const moduleTree = buildModuleTree(sources, resolutions, { vendorDir: VENDOR_DIR })
232
184
  for (const [path, content] of sources) {
233
- // Vendored crates keep their (file-relative) `mod` edges from above, but
234
- // their `crate::`/external `use` edges are dropped rather than mis-resolved
235
- // against the project crate's module tree.
185
+ // Vendored crates keep their `mod` edges but skip `crate::`/`use` resolution
186
+ // (their edges would mis-resolve against the project crate's tree).
236
187
  if (path.startsWith(`${VENDOR_DIR}/`)) continue
237
188
  const specMap = resolutions.get(path)
238
189
  for (const usePath of extractCrateUses(content)) {
@@ -241,8 +192,6 @@ export function buildRustTree(sources) {
241
192
  specMap.set(usePath, resolved)
242
193
  }
243
194
  }
244
- // A `use <crate>::…` of a vendored crate becomes a `use <crate>` edge to
245
- // that crate's root, recording the cross-crate dependency in the graph.
246
195
  for (const crate of extractExternalCrates(content)) {
247
196
  const lib = resolveVendoredCrate(crate, { knownSources: sources })
248
197
  const spec = `use ${crate}`
@@ -252,9 +201,8 @@ export function buildRustTree(sources) {
252
201
  return { sources, resolutions, missing }
253
202
  }
254
203
 
255
- // Walk the filesystem from `entries`, following `mod` declarations and reading
256
- // each reachable file once. A `mod` whose file is missing on disk warns and is
257
- // skipped; the walk continues. Files in a wave are read in parallel.
204
+ // Walk from `entries` following `mod` decls, reading each reachable file once;
205
+ // a missing file warns and is skipped without aborting the walk.
258
206
  export async function collectRustFilesFromDisk(baseDir, entries) {
259
207
  const sources = new Map()
260
208
  const realBase = realpathSync(baseDir)
@@ -272,8 +220,7 @@ export async function collectRustFilesFromDisk(baseDir, entries) {
272
220
  console.warn(`[loader.rust] Missing file: ${relPath}`)
273
221
  return null
274
222
  }
275
- // resolveModPath gates candidates with isFile so a directory is
276
- // normally never queued; guard EISDIR anyway (symmetry with bash).
223
+ // resolveModPath gates on isFile so a dir is normally never queued; guard EISDIR anyway.
277
224
  if (err.code === 'EISDIR') {
278
225
  console.warn(`[loader.rust] Skipping directory reference: ${relPath}`)
279
226
  return null
@@ -291,8 +238,7 @@ export async function collectRustFilesFromDisk(baseDir, entries) {
291
238
  const resolved = resolveModPath(name, relPath, { baseDir })
292
239
  if (resolved && !sources.has(resolved)) next.push(resolved)
293
240
  }
294
- // Follow `use <crate>::…` into a vendored crate's root; its own `mod`
295
- // edges (read on the next wave) then pull in the rest of the crate.
241
+ // Follow `use <crate>::…` to the vendored root; its `mod` edges pull in the rest next wave.
296
242
  for (const crate of extractExternalCrates(content)) {
297
243
  const lib = resolveVendoredCrate(crate, { baseDir })
298
244
  if (lib && !sources.has(lib)) next.push(lib)
@@ -315,10 +261,8 @@ function assertWithinBase(baseDir, candidate, label) {
315
261
  }
316
262
  }
317
263
 
318
- // High-level: takes a `.rs.txt` listing whose lines are crate entry points
319
- // (typically src/main.rs or src/lib.rs), resolved relative to the listing.
320
- // Walks `mod` declarations from there, then resolves `crate::` references.
321
- // Returns { sources, resolutions, missing }.
264
+ // High-level entry: reads a `.rs.txt` listing of crate entry points (relative to
265
+ // the listing), then walks `mod`/`crate::` from there.
322
266
  export async function loadRust(rsTxtFile) {
323
267
  const baseDir = dirname(resolve(rsTxtFile))
324
268
  const listing = await readFile(rsTxtFile, 'utf8')
@@ -1,9 +1,7 @@
1
1
  // Based on DeepView's Solidity loader.
2
2
  // https://github.com/PreventiveMeasures/deepview/blob/main/src/loaders/solidity.js
3
- //
4
- // Adapted to produce a `{ sources, resolutions }` pair only. The mapping
5
- // file (foundry.toml or remappings.txt) is read to extract `remappings`,
6
- // but is itself not included in `sources`.
3
+ // Produces a `{ sources, resolutions }` pair; the mapping file (foundry.toml /
4
+ // remappings.txt) is read for `remappings` but not included in `sources`.
7
5
 
8
6
  import { realpathSync, statSync } from 'node:fs'
9
7
  import { readFile } from 'node:fs/promises'
@@ -32,20 +30,11 @@ export function parseRemappings(content) {
32
30
  }).filter(Boolean)
33
31
  }
34
32
 
35
- // Resolve a Solidity import specifier to a baseDir-relative POSIX path,
36
- // trying four strategies in order:
37
- // 1. user remappings (longest prefix wins)
38
- // 2. relative path (`./...` or `../...`); traversal above the project
39
- // root returns null instead of clamping
40
- // 3. Node's CJS resolver via createRequire, anchored at the importing
41
- // source — only when `baseDir` is provided and the specifier looks
42
- // like a scoped npm package (`@scope/pkg/sub/path`)
43
- // 4. project-relative fallback: when `baseDir` is provided and a file
44
- // exists at `<baseDir>/<specifier>`, accept the specifier as-is.
45
- // Equivalent to an implicit `<top>/=<top>/` remapping for any
46
- // top-level directory in the project. Lets Foundry-style
47
- // `import "src/Foo.sol"` work without an explicit remapping.
48
- // Returns null when no strategy resolves the import.
33
+ // Resolve a Solidity import to a baseDir-relative POSIX path, trying in order:
34
+ // (1) remappings (longest prefix wins); (2) relative `./`/`../` (root-escape ->
35
+ // null, not clamped); (3) Node CJS resolver for scoped npm packages
36
+ // (`@scope/pkg/...`); (4) project-relative fallback if the file exists. Returns
37
+ // null when nothing resolves.
49
38
  export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
50
39
  let best = null
51
40
  for (const { prefix, target } of remappings) {
@@ -75,12 +64,9 @@ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir
75
64
  return resolved.join('/')
76
65
  }
77
66
 
78
- // Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
79
- // importing source so nested node_modules resolve like Node would
80
- // (a file under `node_modules/foo/` finds its deps in
81
- // `foo/node_modules/` before walking up). `..` segments in the
82
- // subpath are rejected so a crafted specifier can't escape the
83
- // resolved package via require.resolve's own path normalization.
67
+ // Node-style `@scope/pkg/sub/path`. createRequire is anchored at the importing
68
+ // source so nested node_modules resolve like Node. `..` segments in the subpath
69
+ // are rejected so a crafted specifier can't escape the resolved package.
84
70
  if (baseDir && specifier.startsWith('@')) {
85
71
  const parts = specifier.split('/')
86
72
  if (parts.length < 3) return null
@@ -92,12 +78,9 @@ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir
92
78
  } catch { /* package missing, exports map blocks the subpath, … */ }
93
79
  }
94
80
 
95
- // Project-relative fallback: when nothing else resolved, accept the
96
- // spec as-is if a real file sits at `<baseDir>/<spec>`. statSync's
97
- // isFile() guard keeps directories and other non-files from being
98
- // queued for readFile (which would surface as EISDIR mid-walk). `./`
99
- // and `../` specs are already routed through the relative branch
100
- // above, so we only see bare specs here.
81
+ // Project-relative fallback: accept the spec as-is if a real file sits at
82
+ // `<baseDir>/<spec>`. The isFile() guard keeps directories out of the read
83
+ // queue (they'd surface as EISDIR mid-walk). Only bare specs reach here.
101
84
  if (baseDir && !isAbsolute(specifier)) {
102
85
  const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
103
86
  if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
@@ -110,13 +93,10 @@ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir
110
93
  return null
111
94
  }
112
95
 
113
- // Build the { sources, resolutions, missing } triple from a Map of
114
- // already-loaded Solidity sources plus optional remappings. Imports that
115
- // resolve via remappings/relative/Node win; as a final fallback an
116
- // import specifier that matches a stored key verbatim (etherscan
117
- // bundles, hardhat flat layouts) is accepted. `missing` lists every
118
- // (spec, from) pair that could not be resolved or that resolved to a
119
- // file not loaded into `sources`.
96
+ // Build `{ sources, resolutions, missing }` from already-loaded Solidity sources
97
+ // plus optional remappings. Imports resolve via remappings/relative/Node; as a
98
+ // final fallback a specifier matching a stored key verbatim is accepted. `missing`
99
+ // lists every (spec, from) pair that didn't resolve or resolved outside `sources`.
120
100
  export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
121
101
  const resolutions = new Map()
122
102
  const missing = []
@@ -138,12 +118,9 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
138
118
  return { sources, resolutions, missing }
139
119
  }
140
120
 
141
- // Walk the filesystem starting from `entries`, following resolved
142
- // imports and reading each file exactly once. Files in the same wave
143
- // are read in parallel; the next wave depends on the imports
144
- // discovered in the previous one. Caller-listed entries are also
145
- // accepted as verbatim non-relative import targets (Foundry-style
146
- // `import "src/A.sol"`).
121
+ // Walk the filesystem from `entries`, following resolved imports and reading each
122
+ // file once (same-wave reads run in parallel). Caller-listed entries are also
123
+ // accepted as verbatim non-relative import targets (Foundry-style `import "src/A.sol"`).
147
124
  export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
148
125
  const sources = new Map()
149
126
  const knownEntries = new Set(entries)
@@ -188,17 +165,16 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
188
165
  return sources
189
166
  }
190
167
 
191
- // Read a foundry.toml or remappings.txt mapping file and return its parsed
192
- // remappings. The mapping file itself is NOT included in the loaded sources.
168
+ // Read a foundry.toml/remappings.txt mapping file -> parsed remappings (the file
169
+ // itself is not added to sources).
193
170
  export async function readRemappingsFile(mappingFile) {
194
171
  const content = await readFile(mappingFile, 'utf8')
195
172
  if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
196
173
  return parseRemappings(content)
197
174
  }
198
175
 
199
- // Reject absolute paths and `..`-escaping paths in a `.sol.txt` listing so
200
- // a malicious or sloppy listing can't trick the loader into reading
201
- // arbitrary files outside the listing's own directory.
176
+ // Reject absolute and `..`-escaping paths in a `.sol.txt` listing so it can't
177
+ // trick the loader into reading files outside the listing's directory.
202
178
  function assertWithinBase(baseDir, candidate, label) {
203
179
  if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
204
180
  const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
@@ -207,10 +183,9 @@ function assertWithinBase(baseDir, candidate, label) {
207
183
  }
208
184
  }
209
185
 
210
- // High-level: takes an optional .sol.txt listing file. The first line may
211
- // be a `*.toml` or `remappings.txt` mapping file (resolved relative to the
212
- // listing); the remaining lines are `*.sol` files. Mirrors the DeepView
213
- // loader's interface.
186
+ // High-level entry: a `.sol.txt` listing whose optional first line is a
187
+ // `*.toml`/`remappings.txt` mapping file (resolved relative to the listing); the
188
+ // remaining lines are `*.sol` files.
214
189
  export async function loadSolidity(solTxtFile) {
215
190
  const baseDir = dirname(resolve(solTxtFile))
216
191
  const listing = await readFile(solTxtFile, 'utf8')
@@ -0,0 +1,165 @@
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { createRequire, isBuiltin } from 'node:module'
3
+ import { dirname, isAbsolute, join } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { isTypeDeclaration } from '@exodus/stasis-core/util'
7
+
8
+ // Adapter that drives the PROJECT's own `metro-resolver` from stasis's static scanner, so
9
+ // `stasis bundle --metro --metro-resolver` resolves modules EXACTLY the way that project's
10
+ // installed Metro does -- rather than the hand-rolled Metro/RN approximation in
11
+ // resolve-fields.js. `metro-resolver` is a leaf of the Metro toolchain (it takes a context of
12
+ // filesystem callbacks and does no I/O of its own), so we load whichever copy the project
13
+ // already ships and feed it a synchronous, disk-backed ResolutionContext.
14
+ //
15
+ // Returns, for each specifier, the same shape scan.js expects from a custom resolver:
16
+ // { url } resolved to a real file (file: URL string)
17
+ // { empty } a browser/react-native field mapped it to `false` (empty module)
18
+ // { builtin } a Node builtin (Metro itself has none; we classify a bare-name miss)
19
+ // null unresolved
20
+ //
21
+ // It is deliberately version-agnostic: it never imports metro-resolver internals (the
22
+ // `./private/*` subpath), only the public `resolve`. `resolve` applies the browser-field
23
+ // redirect via its own bundled helper, so the `redirectModulePath` we place on the context is
24
+ // an inert placeholder that satisfies the type but is never consulted for base resolution.
25
+
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
+ // 'f' | 'd' | null for a path, without throwing.
42
+ 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
+ }
49
+ }
50
+
51
+ // A `.d.ts` is types-only, erased at runtime, so the resolver must never land on one: report it as
52
+ // non-existent to Metro's filesystem hooks and a package whose entry points at a declaration falls
53
+ // through to its real `.js`. Only the RESOLUTION hooks use this -- getPackageForModule's own
54
+ // dir-vs-file probe stays on raw pathType (it locates package scopes, never a module target).
55
+ const resolvableType = (p) => (isTypeDeclaration(p) ? null : pathType(p))
56
+
57
+ // Metro's `getPackageForModule`: the closest package scope for an absolute candidate path
58
+ // (which need not exist). The candidate can be a package DIRECTORY (a bare import lands on
59
+ // `<node_modules>/<pkg>`), so probing starts at the path itself, then walks up. `packageRelativePath`
60
+ // is '' when the candidate IS the package root -- Metro's exports resolver maps '' to the '.' subpath,
61
+ // and a stray '.' there would mismatch the exports key.
62
+ function getPackageForModule(absModulePath) {
63
+ let dir = pathType(absModulePath) === 'd' ? absModulePath : dirname(absModulePath)
64
+ while (true) {
65
+ const pkg = readJson(join(dir, 'package.json'))
66
+ if (pkg) {
67
+ const rel = dir === absModulePath ? '' : absModulePath.slice(dir.length + 1)
68
+ return { rootPath: dir, packageJson: pkg, packageRelativePath: rel }
69
+ }
70
+ const parent = dirname(dir)
71
+ if (parent === dir) return null
72
+ dir = parent
73
+ }
74
+ }
75
+
76
+ // Build a resolver bound to a platform. `metro-resolver` is required from `projectDir` so we
77
+ // honour the version the project's Metro uses; a missing package throws with actionable guidance.
78
+ // `sourceExts` are the extensions Metro probes (kept to what a source bundle can carry, so the
79
+ // resolver never lands on a file the bundle would reject). `mainFields` drives the browser-field
80
+ // spec; `conditionNames` are the extra `exports`/`imports` conditions on top of the ones Metro
81
+ // derives itself (default + require|import + platform).
82
+ export function createMetroResolver({
83
+ projectDir,
84
+ platform,
85
+ sourceExts = ['js', 'json', 'ts'],
86
+ mainFields = ['react-native', 'browser', 'main'],
87
+ conditionNames = ['react-native'],
88
+ conditionsByPlatform = { web: ['browser'] },
89
+ enablePackageExports = true,
90
+ } = {}) {
91
+ const require = createRequire(join(projectDir, 'noop.js'))
92
+ let resolve
93
+ try {
94
+ ;({ resolve } = require('metro-resolver'))
95
+ } catch (cause) {
96
+ throw new Error(
97
+ `StasisMetroResolver: couldn't load 'metro-resolver' from ${projectDir}. --metro-resolver ` +
98
+ 'resolves through the project\'s own Metro; install it (it ships with react-native/metro) ' +
99
+ 'or drop --metro-resolver to use the built-in resolver.',
100
+ { cause }
101
+ )
102
+ }
103
+
104
+ // A React Native app has no Node builtins by default; Metro only prefers native suffixes off web.
105
+ const base = {
106
+ allowHaste: false,
107
+ assetExts: new Set(),
108
+ customResolverOptions: {},
109
+ disableHierarchicalLookup: false,
110
+ doesFileExist: (p) => resolvableType(p) === 'f',
111
+ extraNodeModules: null,
112
+ dev: false,
113
+ getPackage: (packageJsonPath) => readJson(packageJsonPath),
114
+ getPackageForModule,
115
+ fileSystemLookup: (p) => {
116
+ const abs = isAbsolute(p) ? p : join(projectDir, p)
117
+ const type = resolvableType(abs)
118
+ if (!type) return { exists: false }
119
+ let realPath = abs
120
+ try {
121
+ realPath = realpathSync(abs)
122
+ } catch { /* keep the lexical path when realpath fails (e.g. a broken symlink) */ }
123
+ return { exists: true, type, realPath }
124
+ },
125
+ mainFields,
126
+ nodeModulesPaths: [],
127
+ preferNativePlatform: platform !== 'web',
128
+ resolveAsset: (dirPath, assetName, ext) => {
129
+ const f = join(dirPath, `${assetName}${ext}`)
130
+ return pathType(f) === 'f' ? [f] : null
131
+ },
132
+ resolveHasteModule: () => null,
133
+ resolveHastePackage: () => null,
134
+ // Inert placeholder: `resolve` redirects via its own bundled helper, never this (see header).
135
+ redirectModulePath: (modulePath) => modulePath,
136
+ sourceExts,
137
+ unstable_conditionNames: conditionNames,
138
+ unstable_conditionsByPlatform: conditionsByPlatform,
139
+ unstable_enablePackageExports: enablePackageExports,
140
+ unstable_incrementalResolution: false,
141
+ unstable_logWarning: () => {}, // a static build must not spam on exports fallbacks
142
+ }
143
+
144
+ return function resolveOne(parentFile, specifier, callConditions) {
145
+ // Metro splits `exports` on whether the edge was an ESM import vs a CJS require. scan.js hands
146
+ // us the parent's format-derived condition set, so 'import' membership is that signal.
147
+ const isESMImport = callConditions instanceof Set ? callConditions.has('import') : false
148
+ const context = { ...base, originModulePath: parentFile, isESMImport }
149
+ let res
150
+ try {
151
+ res = resolve(context, specifier, platform)
152
+ } catch {
153
+ // Metro carries no Node builtins: an unmapped builtin fails to resolve here. The browser-field
154
+ // remap of a builtin (`{"crypto": false}` / a shim) is handled inside resolve() before this.
155
+ if (isBuiltin(specifier)) return { builtin: true }
156
+ return null
157
+ }
158
+ if (!res) return null
159
+ if (res.type === 'empty') return { empty: true }
160
+ if (res.type === 'sourceFile') return { url: pathToFileURL(res.filePath).toString() }
161
+ // Assets are off (assetExts empty) for a code bundle, but map defensively to the first variant.
162
+ if (res.type === 'assetFiles' && res.filePaths?.length) return { url: pathToFileURL(res.filePaths[0]).toString() }
163
+ return null
164
+ }
165
+ }
@@ -1,6 +1,7 @@
1
1
  // Public-export adapter: backs `@exodus/stasis/metro-transformer`. The transformer
2
2
  // lives in @exodus/stasis-plugins (which depends on @exodus/stasis-core for State; it
3
- // drives load-mode getFile directly). Wire it as Metro's `transformer.transformerPath`
4
- // in load mode; see the source for the load asymmetry that lets this run per-worker
3
+ // drives load-mode getFile directly). Wire it permanently as Metro's top-level
4
+ // `transformerPath`; it only acts under bundle=load (transparent pass-through
5
+ // otherwise). See the source for the load asymmetry that lets this run per-worker
5
6
  // without cross-worker state sync.
6
7
  export * from '@exodus/stasis-plugins/metro-transformer'