@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -1,136 +1,354 @@
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. Imports resolve the way solc does under the project's
4
+ // build tool: remappings (discovered the way `forge build` does for a Foundry project, see
5
+ // foundry.js), then a Foundry library's include path, solc's base path (the project root), and
6
+ // Hardhat's/Node's node_modules lookup. The mapping/config files are read, not added to `sources`.
7
+ // Dependencies are untrusted input: an import only ever reaches a `.sol` file inside the project,
8
+ // a dependency's imports only its own and other dependencies' files (by real path), and nothing
9
+ // is read through a link a dependency planted out of itself (solidityOwnership). The project is read
10
+ // through a `host` (@exodus/stasis-core/host), the disk's by default.
7
11
 
8
- import { realpathSync, statSync } from 'node:fs'
9
12
  import { readFile } from 'node:fs/promises'
10
- import { createRequire } from 'node:module'
11
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
13
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
12
14
 
13
- import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
15
+ import { diskHost } from '@exodus/stasis-core/host'
16
+ import { assertRealPathWithinBase, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
17
+ import { isDir, isFile } from '../resolve-typescript.js'
18
+ import {
19
+ FOUNDRY_TOML,
20
+ REMAPPINGS_TXT,
21
+ foundryLibs,
22
+ foundryProfile,
23
+ foundryProject,
24
+ foundryTomlRemappings,
25
+ parseRemappingLines,
26
+ readFoundryTomlRemappings,
27
+ shownFrom,
28
+ toSolcRemapping,
29
+ } from './foundry.js'
30
+ import { decodeUtf8, projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull, solidityOwnership } from './solidity-ownership.js'
31
+ import { assertWithinBase } from './paths.js'
14
32
 
15
- const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
33
+ // --- Import scan ------------------------------------------------------------------------------
16
34
 
17
- export function extractSolImports(content) {
18
- return [...content.matchAll(SOL_IMPORT_RE)].map((m) => m[1])
35
+ // The scan's ASCII classes, by char code: identifier start [A-Za-z_$], identifier part [\w$], digit,
36
+ // and a number literal's [\w.].
37
+ const isIdentStart = (c) => (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c === 95 || c === 36
38
+ const isDigit = (c) => c >= 48 && c <= 57
39
+ const isIdentPart = (c) => isIdentStart(c) || isDigit(c)
40
+ const isNumberPart = (c) => (isIdentPart(c) && c !== 36) || c === 46
41
+ const STRING_ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }
42
+
43
+ const utf8 = (s) => [...Buffer.from(s, 'utf8')]
44
+
45
+ // A string literal starting at `text[i]` (a quote): `{ value, end }`, `value` null when it's
46
+ // unterminated on its line (solc rejects those). A literal is bytes, as in solc: `\xNN` is one
47
+ // byte, `\uNNNN` and plain text their UTF-8, and the path is those bytes read as UTF-8.
48
+ function readStringLiteral(text, i) {
49
+ const quote = text.charCodeAt(i)
50
+ let bytes = null // set at the first escape; until then the literal is a plain slice
51
+ let start = i + 1
52
+ let j = start
53
+ while (j < text.length) {
54
+ const c = text.charCodeAt(j)
55
+ if (c === quote) {
56
+ if (bytes === null) return { value: text.slice(start, j), end: j + 1 }
57
+ bytes.push(...utf8(text.slice(start, j)))
58
+ return { value: Buffer.from(bytes).toString('utf8'), end: j + 1 }
59
+ }
60
+ if (c === 10 || c === 13) return { value: null, end: j }
61
+ if (c !== 92) {
62
+ j++
63
+ continue
64
+ }
65
+ bytes ??= []
66
+ bytes.push(...utf8(text.slice(start, j)))
67
+ const next = text[j + 1]
68
+ if (next === 'x' && /^[\da-f]{2}$/iu.test(text.slice(j + 2, j + 4))) {
69
+ bytes.push(Number.parseInt(text.slice(j + 2, j + 4), 16))
70
+ j += 4
71
+ } else if (next === 'u' && /^[\da-f]{4}$/iu.test(text.slice(j + 2, j + 6))) {
72
+ bytes.push(...utf8(String.fromCodePoint(Number.parseInt(text.slice(j + 2, j + 6), 16))))
73
+ j += 6
74
+ } else if (next === '\r' && text[j + 2] === '\n') {
75
+ j += 3 // line continuation
76
+ } else if (next === '\n' || next === '\r') {
77
+ j += 2
78
+ } else if (next === undefined) {
79
+ return { value: null, end: j + 1 }
80
+ } else {
81
+ bytes.push(...utf8(STRING_ESCAPES[next] ?? next))
82
+ j += 2
83
+ }
84
+ start = j
85
+ }
86
+ return { value: null, end: j }
19
87
  }
20
88
 
21
- export function parseRemappingsFromToml(tomlContent) {
22
- const match = tomlContent.match(/remappings\s*=\s*\[([\s\S]*?)\]/u)
23
- if (!match) return []
24
- return parseRemappings([...match[1].matchAll(/["']([^"']+)["']/gu)].map((m) => m[1]).join('\n'))
89
+ // The path of every import directive, in source order. The text is tokenized far enough to skip
90
+ // comments and string literals, so a commented-out `// import "./Old.sol";` or a string holding
91
+ // the word `import` is never taken for one. An import is the `import` keyword followed, before its
92
+ // `;`, by the path literal: `import "p";`, `import "p" as X;`, `import * as X from "p";`,
93
+ // `import {A, B as C} from "p";` -- over any number of lines.
94
+ export function extractSolImports(content) {
95
+ const specs = []
96
+ const n = content.length
97
+ let inImport = false
98
+ let i = 0
99
+ while (i < n) {
100
+ const c = content.charCodeAt(i)
101
+ const next = content.charCodeAt(i + 1)
102
+ if (c === 47 && next === 47) { // `//`, to the end of the line (`\n` or `\r`, as solc ends it)
103
+ let eol = i + 2
104
+ while (eol < n && content.charCodeAt(eol) !== 10 && content.charCodeAt(eol) !== 13) eol++
105
+ i = eol
106
+ } else if (c === 47 && next === 42) { // `/*`
107
+ const close = content.indexOf('*/', i + 2)
108
+ i = close === -1 ? n : close + 2
109
+ } else if (c === 34 || c === 39) { // `"` or `'`
110
+ const { value, end } = readStringLiteral(content, i)
111
+ // An unterminated literal ends the import too (solc rejects the file).
112
+ if (inImport && value !== null) specs.push(value)
113
+ inImport = false
114
+ i = end
115
+ } else if (isIdentStart(c)) {
116
+ let j = i + 1
117
+ while (j < n && isIdentPart(content.charCodeAt(j))) j++
118
+ if (j - i === 6 && content.startsWith('import', i)) inImport = true
119
+ i = j
120
+ } else if (isDigit(c)) {
121
+ // A number literal (`0x1f`, `1e18`, `1_000`): its letters aren't identifiers.
122
+ let j = i + 1
123
+ while (j < n && isNumberPart(content.charCodeAt(j))) j++
124
+ i = j
125
+ } else {
126
+ if (c === 59) inImport = false // `;`
127
+ i++
128
+ }
129
+ }
130
+ return specs
25
131
  }
26
132
 
133
+ // --- Remappings ---------------------------------------------------------------------------------
134
+
135
+ // Loader-side shape: `{ context, prefix, target }` (context null = global).
136
+ const toLoaderRemapping = ({ context, name, path }) => ({ context, prefix: name, target: path })
137
+
138
+ // remappings.txt text -> remappings as written, one `[context:]prefix=target` per line (lines
139
+ // trimmed, blank ones skipped; an empty target is solc's, valid). A line that isn't one throws.
27
140
  export function parseRemappings(content) {
28
- return content.trim().split('\n').map((entry) => {
29
- const eqIdx = entry.indexOf('=')
30
- if (eqIdx === -1) return null
31
- return { prefix: entry.slice(0, eqIdx), target: entry.slice(eqIdx + 1) }
32
- }).filter(Boolean)
33
- }
34
-
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.
49
- export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
141
+ return parseRemappingLines(content, { emptyPath: true }).map(toLoaderRemapping)
142
+ }
143
+
144
+ // foundry.toml text -> the `remappings` of `[profile.default]`, overlaid by the selected profile's
145
+ // (FOUNDRY_PROFILE in `env`) when it sets them.
146
+ export function parseRemappingsFromToml(tomlContent, { env = process.env } = {}) {
147
+ return foundryTomlRemappings(tomlContent, foundryProfile(env)).map(toLoaderRemapping)
148
+ }
149
+
150
+ // Read a mapping file -> its remappings as listed (no discovery around it) and the files read. A
151
+ // foundry.toml (its selected profile, with its `extends` base) is forge's, and so is a
152
+ // remappings.txt when `forge` says forge reads it: slash-terminated the way forge reads them.
153
+ // Otherwise (solc, Hardhat) a remappings.txt applies as written. Messages name files `show(file)`;
154
+ // files are read through `host`.
155
+ function readMapping(mappingFile, { env, forge, host, show = (f) => f }) {
156
+ if (mappingFile.endsWith('.toml')) {
157
+ const { remappings, files, profiled } = readFoundryTomlRemappings(mappingFile, foundryProfile(env), { show, host })
158
+ return { remappings: remappings.map(toSolcRemapping), files, profiled }
159
+ }
160
+ const name = show(mappingFile)
161
+ const text = readUtf8OrNull(mappingFile, name, host)
162
+ if (text === null) throw new Error(`${name}: no such file`)
163
+ const listed = parseRemappingLines(text, { label: name, emptyPath: !forge })
164
+ return { remappings: listed.map(forge ? toSolcRemapping : toLoaderRemapping), files: [mappingFile] }
165
+ }
166
+
167
+ // Read a foundry.toml/remappings.txt mapping file -> its remappings (see readMapping; `forge`
168
+ // defaults to a remappings.txt applying as written). The file itself is not added to sources.
169
+ export function readRemappingsFile(mappingFile, { env = process.env, forge = false, host = diskHost } = {}) {
170
+ return readMapping(mappingFile, { env, forge, host }).remappings
171
+ }
172
+
173
+ // --- Resolution ---------------------------------------------------------------------------------
174
+
175
+ // What resolves the imports of the project at `baseDir`:
176
+ // `{ remappings, libs, ownership, files, envUsed }`.
177
+ // - `mappingFile` (foundry.toml / remappings.txt): exactly the remappings it lists (see readMapping).
178
+ // - else, with a foundry.toml at the root: what `forge build` uses (foundry.js) -- remappings.txt,
179
+ // the profile's remappings, dependencies' own configs, auto-detected `lib/` remappings and their
180
+ // contexts.
181
+ // - else a remappings.txt at the root (solc / Hardhat 3), taken as written.
182
+ // `libs` are forge's lib dirs whenever the root has a foundry.toml (an absolute import inside a
183
+ // library resolves against it); `ownership` tells the dependencies' files from the project's
184
+ // (solidityOwnership: forge's libs, Soldeer's `dependencies/`, git submodules, node_modules);
185
+ // `files` the project-relative config files read (`../` for one outside the project); `envUsed`
186
+ // the environment variables that shaped the result. The project is read through `host`.
187
+ export function discoverSolidityConfig(baseDir, { mappingFile, env = process.env, host = diskHost } = {}) {
188
+ const forge = isFile(join(baseDir, FOUNDRY_TOML), host)
189
+ if (forge && !mappingFile) return foundryProject(baseDir, { env, host })
190
+ const { libs, profiled, files: libsFiles } = forge ? foundryLibs(baseDir, { env, host }) : { libs: [], profiled: false, files: [] }
191
+ const ownership = projectOwnership(baseDir, libs, { soldeer: forge, host })
192
+ const show = shownFrom(toPosix(resolve(baseDir)), host)
193
+ if (mappingFile) {
194
+ const abs = resolve(baseDir, mappingFile)
195
+ const { remappings, files, profiled: mappingProfiled } = readMapping(abs, { env, forge, host, show })
196
+ // The profile picks the mapping file's remappings (a .toml) or the root foundry.toml's libs: the
197
+ // files read are both's.
198
+ const envUsed = profiled || mappingProfiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []
199
+ return { remappings, libs, ownership, files: [...new Set([...files, ...libsFiles].map((f) => projectRelative(baseDir, f, host)))], envUsed }
200
+ }
201
+ const txt = join(baseDir, REMAPPINGS_TXT)
202
+ if (!isFile(txt, host)) return { remappings: [], libs, ownership, files: [], envUsed: [] }
203
+ return { remappings: readMapping(txt, { env, forge, host, show }).remappings, libs, ownership, files: [REMAPPINGS_TXT], envUsed: [] }
204
+ }
205
+
206
+ // Solc's remapping choice for the source unit `name` imported from `fromFile`: among the
207
+ // remappings whose context is a prefix of `fromFile` and whose prefix is a prefix of `name`, the
208
+ // longest context wins, then the longest prefix, then the one listed last. The target replaces the
209
+ // prefix verbatim (`//` collapsed). Null when none applies.
210
+ export function applyRemappings(name, fromFile, remappings) {
50
211
  let best = null
51
- for (const { prefix, target } of remappings) {
52
- if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
53
- best = { prefix, target }
54
- }
212
+ for (const r of remappings) {
213
+ const context = r.context ?? ''
214
+ if (!fromFile.startsWith(context) || !name.startsWith(r.prefix)) continue
215
+ if (best && (context.length < best.context.length || (context.length === best.context.length && r.prefix.length < best.prefix.length))) continue
216
+ best = { context, prefix: r.prefix, target: r.target }
55
217
  }
56
- if (best) {
57
- const target = best.target.replace(/\/$/u, '')
58
- const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
59
- return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
60
- }
61
-
62
- if (specifier.startsWith('.')) {
63
- const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
64
- const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
65
- const resolved = []
66
- for (const part of parts) {
67
- if (part === '.' || part === '') continue
68
- if (part === '..') {
69
- if (resolved.length === 0) return null
70
- resolved.pop()
71
- } else {
72
- resolved.push(part)
73
- }
74
- }
75
- return resolved.join('/')
76
- }
77
-
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.
84
- if (baseDir && specifier.startsWith('@')) {
85
- const parts = specifier.split('/')
86
- if (parts.length < 3) return null
87
- if (parts.slice(2).includes('..')) return null
88
- try {
89
- const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
90
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
91
- if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
92
- } catch { /* package missing, exports map blocks the subpath, … */ }
93
- }
94
-
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.
101
- if (baseDir && !isAbsolute(specifier)) {
102
- const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
103
- if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
104
- try {
105
- if (statSync(join(baseDir, rel)).isFile()) return rel
106
- } catch { /* missing or not statable */ }
218
+ if (!best) return null
219
+ return posix.normalize(best.target + name.slice(best.prefix.length)).replace(/^\.\//u, '')
220
+ }
221
+
222
+ // A relative import (first segment `.` or `..`) as solc resolves it: against the importing file's
223
+ // directory. Null when it climbs above the root: solc clamps there (`../../B.sol` from `src/A.sol`
224
+ // is `B.sol`), but an import reaching out of the project is refused rather than redirected.
225
+ function resolveRelativeImport(specifier, fromFile) {
226
+ const resolved = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')).split('/') : []
227
+ for (const part of specifier.split('/')) {
228
+ if (part === '.' || part === '') continue
229
+ if (part === '..') {
230
+ if (resolved.length === 0) return null
231
+ resolved.pop()
232
+ } else {
233
+ resolved.push(part)
107
234
  }
108
235
  }
236
+ return resolved.join('/')
237
+ }
238
+
239
+ const isRelativeImport = (specifier) => {
240
+ const first = specifier.split('/')[0]
241
+ return first === '.' || first === '..'
242
+ }
243
+
244
+ // `<baseDir>/<spec>` when a real file sits there, as a clean project-relative path.
245
+ function projectFile(baseDir, spec, host) {
246
+ if (isAbsolute(spec)) return null
247
+ const rel = toPosix(relative(baseDir, resolve(baseDir, spec)))
248
+ if (rel === '' || relativeEscapes(rel)) return null
249
+ return isFile(join(baseDir, rel), host) ? rel : null
250
+ }
251
+
252
+ // No `.`/`..`/empty segment: a bare spec can't wander out of the directory it's looked up in.
253
+ const isPlainSpec = (spec) => spec.split('/').every((p) => p !== '' && p !== '.' && p !== '..')
109
254
 
255
+ // Forge's absolute import inside a library (`lib/dep/src/A.sol` importing `src/B.sol`): tried
256
+ // against each directory from the parent of the importer's up to (not including) its lib dir, as
257
+ // foundry-compilers' `resolve_absolute_library` does; forge passes the matching one to solc as an
258
+ // include path.
259
+ function libraryFile(baseDir, spec, fromFile, libs, host) {
260
+ const lib = libs.map((l) => posix.normalize(toPosix(l)).replace(/\/$/u, '')).find((l) => fromFile.startsWith(`${l}/`))
261
+ if (!lib) return null
262
+ for (let dir = posix.dirname(posix.dirname(fromFile)); dir !== lib && dir.startsWith(`${lib}/`); dir = posix.dirname(dir)) {
263
+ const hit = projectFile(baseDir, `${dir}/${spec}`, host)
264
+ if (hit) return hit
265
+ }
110
266
  return null
111
267
  }
112
268
 
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`.
120
- export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
269
+ // A package import (`pkg/path.sol`, `@scope/pkg/path.sol`) by file path through node_modules, from
270
+ // the importing file's directory up to the root (Hardhat and Node; a package's `exports` map
271
+ // doesn't apply to Solidity sources).
272
+ function nodeModulesFile(baseDir, spec, fromFile, host) {
273
+ const parts = spec.split('/')
274
+ if (parts.length < (spec.startsWith('@') ? 3 : 2)) return null
275
+ for (let dir = posix.dirname(fromFile); ; dir = posix.dirname(dir)) {
276
+ if (posix.basename(dir) !== 'node_modules') {
277
+ const hit = projectFile(baseDir, dir === '.' ? `node_modules/${spec}` : `${dir}/node_modules/${spec}`, host)
278
+ if (hit) return hit
279
+ }
280
+ if (dir === '.' || dir === '/' || dir === '') return null
281
+ }
282
+ }
283
+
284
+ // Where an import resolves, as `{ path }`, or `{ reason }` when it may not be read (`reason: null`:
285
+ // it names no file). See resolveSolImport.
286
+ function resolveImport(specifier, fromFile, { remappings = [], baseDir, libs = [], ownership, host = diskHost } = {}) {
287
+ const relativeImport = isRelativeImport(specifier)
288
+ const name = relativeImport ? resolveRelativeImport(specifier, fromFile) : specifier
289
+ if (name === null) return { reason: 'it climbs above the project root' }
290
+ let path = applyRemappings(name, fromFile, remappings)
291
+ if (path === null && relativeImport) path = name
292
+ if (path === null && baseDir) {
293
+ const plain = isPlainSpec(name)
294
+ path = (plain ? libraryFile(baseDir, name, fromFile, libs, host) : null) ?? projectFile(baseDir, name, host) ?? (plain ? nodeModulesFile(baseDir, name, fromFile, host) : null)
295
+ }
296
+ if (path === null) return { reason: null }
297
+ if (isAbsolute(path) || posix.isAbsolute(path) || path === '..' || path.startsWith('../')) return { reason: `it resolves to ${path}, outside the project root` }
298
+ if (!path.endsWith('.sol')) return { reason: `it resolves to ${path}, which is not a .sol file` }
299
+ if (!baseDir) return { path }
300
+ const own = ownership ?? solidityOwnership(baseDir, { host })
301
+ const target = own.of(path)
302
+ if (target.reason) return { reason: target.reason }
303
+ // (A link out of the root is refused when the file is read.)
304
+ if (target.real !== null && !target.outside && !target.dependency && own.of(fromFile).dependency) return { reason: `a dependency may not import the project's own ${path}` }
305
+ return { path }
306
+ }
307
+
308
+ // Resolve a Solidity import to a baseDir-relative POSIX path, the way solc does: a relative import
309
+ // (`./`, `../`) is taken against the importing file (root escape -> null), then remappings apply
310
+ // (longest context, then longest prefix; see applyRemappings). An unremapped non-relative import
311
+ // is then looked up, when `baseDir` is given, inside the importer's library (forge's include path;
312
+ // `libs` are forge's lib dirs), as a project file (solc's base path), and through node_modules by
313
+ // file path (Hardhat / Node). Returns null when nothing resolves, or when the result isn't a `.sol`
314
+ // file inside the root, crosses a link a dependency planted out of itself, or is the project's own
315
+ // file imported by a dependency's -- by real path, with `ownership` (solidityOwnership's; by default
316
+ // only node_modules holds dependencies). The project is read through `host`.
317
+ export function resolveSolImport(specifier, fromFile, options = {}) {
318
+ return resolveImport(specifier, fromFile, options).path ?? null
319
+ }
320
+
321
+ // The files that describe a Solidity build (bundled by `--manifests`) besides the config files
322
+ // discovery read: the root's dependency pins, and each package's manifests. (`hardhat.config.*` is
323
+ // code that may hold keys, so it is never carried.)
324
+ export const SOLIDITY_ROOT_MANIFESTS = [FOUNDRY_TOML, REMAPPINGS_TXT, 'foundry.lock', 'soldeer.lock', '.gitmodules', 'package.json']
325
+ export const SOLIDITY_PACKAGE_MANIFESTS = ['package.json', FOUNDRY_TOML, REMAPPINGS_TXT]
326
+
327
+ // --- The walk -----------------------------------------------------------------------------------
328
+
329
+ const warnUnresolved = (spec, from, reason) =>
330
+ console.warn(`[loader.solidity] ${reason ? 'Refused' : 'Missing'} import: ${spec} from ${from}${reason ? ` (${reason})` : ''}`)
331
+
332
+ // Build `{ sources, resolutions, missing }` from already-loaded Solidity sources plus remappings.
333
+ // Imports resolve as resolveSolImport does (`libs`, `ownership`, `host`: see there); as a final
334
+ // fallback a specifier naming no file but matching a stored key verbatim is accepted. `missing`
335
+ // lists every `{ spec, from }` that didn't resolve or resolved outside `sources`, with the
336
+ // `reason` when it was refused.
337
+ export function buildSolidityTree(sources, { remappings = [], baseDir, libs = [], host = diskHost, ownership = baseDir && solidityOwnership(baseDir, { host }) } = {}) {
121
338
  const resolutions = new Map()
122
339
  const missing = []
340
+ const options = { remappings, baseDir, libs, ownership, host }
123
341
  for (const [path, content] of sources) {
124
342
  const specMap = new Map()
125
343
  for (const spec of extractSolImports(content)) {
126
- let resolved = resolveSolImport(spec, path, { remappings, baseDir })
127
- if (resolved && !sources.has(resolved)) resolved = null
128
- if (!resolved && sources.has(spec)) resolved = spec
344
+ const r = resolveImport(spec, path, options)
345
+ let resolved = r.path && sources.has(r.path) ? r.path : null
346
+ if (!resolved && !r.reason && sources.has(spec)) resolved = spec
129
347
  if (resolved) {
130
348
  specMap.set(spec, resolved)
131
349
  } else {
132
- console.warn(`[loader.solidity] Missing import: ${spec} from ${path}`)
133
- missing.push({ spec, from: path })
350
+ warnUnresolved(spec, path, r.reason)
351
+ missing.push(r.reason ? { spec, from: path, reason: r.reason } : { spec, from: path })
134
352
  }
135
353
  }
136
354
  resolutions.set(path, specMap)
@@ -138,90 +356,121 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
138
356
  return { sources, resolutions, missing }
139
357
  }
140
358
 
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"`).
147
- export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
359
+ // Walk the project from `entries`, following resolved imports and reading each file once, a wave at
360
+ // a time: the files of one, then the imports they name. Caller-listed entries are also accepted as
361
+ // verbatim non-relative import targets naming no file (Foundry-style `import "src/A.sol"`). An entry
362
+ // that crosses a dependency's link out of itself (see solidityOwnership) is refused. The project is
363
+ // read through `host`.
364
+ export function collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs = [], host = diskHost, ownership = solidityOwnership(baseDir, { host }) } = {}) {
148
365
  const sources = new Map()
149
366
  const knownEntries = new Set(entries)
150
- const realBase = realpathSync(baseDir)
151
-
152
- const processWave = async (wave) => {
153
- const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
154
- if (toLoad.length === 0) return
155
- const reads = await Promise.all(
156
- toLoad.map(async (relPath) => {
157
- try {
158
- assertRealPathWithinBase(realBase, baseDir, relPath)
159
- return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
160
- } catch (err) {
161
- if (err.code === 'ENOENT') {
162
- console.warn(`[loader.solidity] Missing import: ${relPath}`)
163
- return null
164
- }
165
- throw err
367
+ const realBase = host.realpath(baseDir)
368
+ const options = { remappings, baseDir, libs, ownership, host }
369
+ for (const entry of entries) ownership.assert(entry, 'entry ')
370
+ for (let wave = entries; wave.length > 0;) {
371
+ const reads = [...new Set(wave)].filter((p) => !sources.has(p)).map((relPath) => {
372
+ try {
373
+ assertRealPathWithinBase(realBase, baseDir, relPath, host)
374
+ return [relPath, decodeUtf8(host.readFile(join(baseDir, relPath)), relPath)]
375
+ } catch (err) {
376
+ if (err.code === 'ENOENT') {
377
+ console.warn(`[loader.solidity] Missing import: ${relPath}`)
378
+ return null
166
379
  }
167
- })
168
- )
380
+ throw err
381
+ }
382
+ })
169
383
  const next = []
170
384
  for (const entry of reads) {
171
385
  if (!entry) continue
172
386
  const [relPath, content] = entry
173
387
  sources.set(relPath, content)
174
388
  for (const spec of extractSolImports(content)) {
175
- let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
176
- if (!resolved && knownEntries.has(spec)) resolved = spec
389
+ const r = resolveImport(spec, relPath, options)
390
+ const resolved = r.path ?? (!r.reason && knownEntries.has(spec) ? spec : null)
177
391
  if (resolved) {
178
392
  if (!sources.has(resolved)) next.push(resolved)
179
393
  } else {
180
- console.warn(`[loader.solidity] Missing import: ${spec} from ${relPath}`)
394
+ warnUnresolved(spec, relPath, r.reason)
181
395
  }
182
396
  }
183
397
  }
184
- await processWave(next)
398
+ wave = next
185
399
  }
186
-
187
- await processWave(entries)
188
400
  return sources
189
401
  }
190
402
 
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.
193
- export async function readRemappingsFile(mappingFile) {
194
- const content = await readFile(mappingFile, 'utf8')
195
- if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
196
- return parseRemappings(content)
403
+ // Every `.sol` file under the project-relative directory `dir`, sorted, symlinks followed as forge
404
+ // (walkdir, `follow_links`) collects a source dir: a symlinked directory whose real path is one on
405
+ // the current walk (the walk root included) is a loop and skipped; anything else is walked, so two
406
+ // links to one directory are two directories. A dir's real path is its parent's plus its name
407
+ // unless it's a symlink.
408
+ function solidityFilesUnder(baseDir, dir, host) {
409
+ const out = []
410
+ // The real paths of the directories on the current walk; one may repeat (a real directory
411
+ // reached again through a link is walked, as walkdir does, and checked only at links).
412
+ const stack = []
413
+ const walk = (rel, real) => {
414
+ stack.push(real)
415
+ for (const e of host.readdir(join(baseDir, rel))) {
416
+ const child = rel === '.' ? e.name : `${rel}/${e.name}`
417
+ let kind = e
418
+ let childReal = join(real, e.name)
419
+ if (e.isSymbolicLink()) {
420
+ childReal = realpathOrNull(join(baseDir, child), host)
421
+ kind = host.stat(join(baseDir, child))
422
+ if (kind === null) continue
423
+ if (kind.isDirectory() && (childReal === null || stack.includes(childReal))) continue
424
+ }
425
+ if (kind.isDirectory()) walk(child, childReal)
426
+ else if (kind.isFile() && e.name.endsWith('.sol')) out.push(child)
427
+ }
428
+ stack.pop()
429
+ }
430
+ const root = realpathOrNull(join(baseDir, dir), host)
431
+ if (root !== null) walk(dir, root)
432
+ return out.toSorted()
197
433
  }
198
434
 
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.
202
- function assertWithinBase(baseDir, candidate, label) {
203
- if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
204
- const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
205
- if (rel.startsWith('..') || isAbsolute(rel)) {
206
- throw new Error(`${label} escapes baseDir: ${candidate}`)
435
+ // Project-relative entries with each directory replaced by the `.sol` files under it (deduped, in
436
+ // order). A `.sol` entry is kept as is (a missing one is reported by the walk); a directory that
437
+ // is missing or holds no `.sol` file is skipped with a warning, as forge skips an absent `script/`,
438
+ // and it's an error only when no entry yields a file (when none exists, a mistyped path). The
439
+ // project is read through `host`.
440
+ export function expandSolidityEntries(baseDir, entries, host = diskHost) {
441
+ const out = new Set()
442
+ const shown = (entry) => (entry === '.' ? './' : `${entry}/`)
443
+ for (const e of entries) {
444
+ const entry = e === '' ? '.' : e
445
+ const dir = isDir(join(baseDir, entry), host)
446
+ if (!dir && entry.endsWith('.sol')) {
447
+ out.add(entry)
448
+ continue
449
+ }
450
+ const files = dir ? solidityFilesUnder(baseDir, entry, host) : []
451
+ if (files.length === 0) console.warn(`[stasis] Skipping ${shown(entry)}: ${dir ? 'no .sol files under it' : 'no such directory'}`)
452
+ for (const f of files) out.add(f)
453
+ }
454
+ if (out.size === 0) {
455
+ if (entries.every((e) => host.stat(join(baseDir, e)) === null)) throw new Error(`No such file or directory: ${entries[0]}`)
456
+ throw new Error(`No .sol files under ${entries.map((e) => shown(e === '' ? '.' : e)).join(', ')} (a directory entry stands for the Solidity sources under it)`)
207
457
  }
458
+ return [...out]
208
459
  }
209
460
 
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.
214
- export async function loadSolidity(solTxtFile) {
461
+ // High-level entry: a `.sol.txt` listing whose optional first line is a `*.toml`/`remappings.txt`
462
+ // mapping file (resolved relative to the listing); the remaining lines are `*.sol` files. Without
463
+ // a mapping line, the remappings are discovered as for `stasis bundle` (discoverSolidityConfig).
464
+ export async function loadSolidity(solTxtFile, { env = process.env } = {}) {
215
465
  const baseDir = dirname(resolve(solTxtFile))
216
- const listing = await readFile(solTxtFile, 'utf8')
466
+ const listing = decodeUtf8(await readFile(solTxtFile), solTxtFile)
217
467
  const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
218
468
  if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
219
469
 
220
- let remappings = []
470
+ let mappingFile
221
471
  if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
222
- const mapping = lines.shift()
223
- assertWithinBase(baseDir, mapping, 'Mapping path')
224
- remappings = await readRemappingsFile(join(baseDir, mapping))
472
+ mappingFile = lines.shift()
473
+ assertWithinBase(baseDir, mappingFile, 'Mapping path')
225
474
  }
226
475
 
227
476
  if (!lines.every((line) => line.endsWith('.sol'))) {
@@ -230,6 +479,7 @@ export async function loadSolidity(solTxtFile) {
230
479
 
231
480
  const entries = lines.map((l) => l.replace(/^\.\//u, ''))
232
481
  for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
233
- const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
234
- return buildSolidityTree(sources, { remappings, baseDir })
482
+ const { remappings, libs, ownership } = discoverSolidityConfig(baseDir, { mappingFile, env })
483
+ const sources = collectSolidityFilesFromDisk(baseDir, entries, remappings, { libs, ownership })
484
+ return buildSolidityTree(sources, { remappings, baseDir, libs, ownership })
235
485
  }