@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.
- package/README.md +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/loaders/rust.js
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
// Rust loader:
|
|
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
|
|
5
|
-
//
|
|
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;`
|
|
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
|
|
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
|
|
35
|
-
//
|
|
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
|
-
//
|
|
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
|
|
48
|
-
// `mod
|
|
49
|
-
//
|
|
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>;`
|
|
61
|
-
//
|
|
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
|
|
88
|
-
//
|
|
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
|
|
105
|
-
//
|
|
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
|
-
//
|
|
126
|
-
//
|
|
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
|
|
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
|
-
//
|
|
158
|
-
//
|
|
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
|
-
//
|
|
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
|
|
195
|
-
//
|
|
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
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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:
|
|
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
|
|
234
|
-
// their
|
|
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
|
|
256
|
-
//
|
|
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
|
|
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>::…`
|
|
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:
|
|
319
|
-
//
|
|
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')
|
package/src/loaders/solidity.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
-
//
|
|
80
|
-
//
|
|
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:
|
|
96
|
-
// spec
|
|
97
|
-
//
|
|
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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
192
|
-
//
|
|
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
|
|
200
|
-
//
|
|
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:
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
+
}
|
package/src/metro-transformer.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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'
|