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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,116 +1,354 @@
1
1
  // Based on DeepView's Solidity loader.
2
2
  // https://github.com/PreventiveMeasures/deepview/blob/main/src/loaders/solidity.js
3
- // Produces a `{ sources, resolutions }` pair; the mapping file (foundry.toml /
4
- // remappings.txt) is read for `remappings` but 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.
5
11
 
6
- import { realpathSync, statSync } from 'node:fs'
7
12
  import { readFile } from 'node:fs/promises'
8
- import { createRequire } from 'node:module'
9
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
13
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
10
14
 
11
- 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'
12
32
 
13
- const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
33
+ // --- Import scan ------------------------------------------------------------------------------
14
34
 
15
- export function extractSolImports(content) {
16
- 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 }
17
87
  }
18
88
 
19
- export function parseRemappingsFromToml(tomlContent) {
20
- const match = tomlContent.match(/remappings\s*=\s*\[([\s\S]*?)\]/u)
21
- if (!match) return []
22
- 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
23
131
  }
24
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.
25
140
  export function parseRemappings(content) {
26
- return content.trim().split('\n').map((entry) => {
27
- const eqIdx = entry.indexOf('=')
28
- if (eqIdx === -1) return null
29
- return { prefix: entry.slice(0, eqIdx), target: entry.slice(eqIdx + 1) }
30
- }).filter(Boolean)
31
- }
32
-
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.
38
- 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) {
39
211
  let best = null
40
- for (const { prefix, target } of remappings) {
41
- if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
42
- best = { prefix, target }
43
- }
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 }
44
217
  }
45
- if (best) {
46
- const target = best.target.replace(/\/$/u, '')
47
- const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
48
- return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
49
- }
50
-
51
- if (specifier.startsWith('.')) {
52
- const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
53
- const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
54
- const resolved = []
55
- for (const part of parts) {
56
- if (part === '.' || part === '') continue
57
- if (part === '..') {
58
- if (resolved.length === 0) return null
59
- resolved.pop()
60
- } else {
61
- resolved.push(part)
62
- }
63
- }
64
- return resolved.join('/')
65
- }
66
-
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.
70
- if (baseDir && specifier.startsWith('@')) {
71
- const parts = specifier.split('/')
72
- if (parts.length < 3) return null
73
- if (parts.slice(2).includes('..')) return null
74
- try {
75
- const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
76
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
77
- if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
78
- } catch { /* package missing, exports map blocks the subpath, … */ }
79
- }
80
-
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.
84
- if (baseDir && !isAbsolute(specifier)) {
85
- const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
86
- if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
87
- try {
88
- if (statSync(join(baseDir, rel)).isFile()) return rel
89
- } 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)
90
234
  }
91
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 !== '..')
92
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
+ }
93
266
  return null
94
267
  }
95
268
 
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`.
100
- 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 }) } = {}) {
101
338
  const resolutions = new Map()
102
339
  const missing = []
340
+ const options = { remappings, baseDir, libs, ownership, host }
103
341
  for (const [path, content] of sources) {
104
342
  const specMap = new Map()
105
343
  for (const spec of extractSolImports(content)) {
106
- let resolved = resolveSolImport(spec, path, { remappings, baseDir })
107
- if (resolved && !sources.has(resolved)) resolved = null
108
- 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
109
347
  if (resolved) {
110
348
  specMap.set(spec, resolved)
111
349
  } else {
112
- console.warn(`[loader.solidity] Missing import: ${spec} from ${path}`)
113
- 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 })
114
352
  }
115
353
  }
116
354
  resolutions.set(path, specMap)
@@ -118,85 +356,121 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
118
356
  return { sources, resolutions, missing }
119
357
  }
120
358
 
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"`).
124
- 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 }) } = {}) {
125
365
  const sources = new Map()
126
366
  const knownEntries = new Set(entries)
127
- const realBase = realpathSync(baseDir)
128
-
129
- const processWave = async (wave) => {
130
- const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
131
- if (toLoad.length === 0) return
132
- const reads = await Promise.all(
133
- toLoad.map(async (relPath) => {
134
- try {
135
- assertRealPathWithinBase(realBase, baseDir, relPath)
136
- return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
137
- } catch (err) {
138
- if (err.code === 'ENOENT') {
139
- console.warn(`[loader.solidity] Missing import: ${relPath}`)
140
- return null
141
- }
142
- 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
143
379
  }
144
- })
145
- )
380
+ throw err
381
+ }
382
+ })
146
383
  const next = []
147
384
  for (const entry of reads) {
148
385
  if (!entry) continue
149
386
  const [relPath, content] = entry
150
387
  sources.set(relPath, content)
151
388
  for (const spec of extractSolImports(content)) {
152
- let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
153
- 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)
154
391
  if (resolved) {
155
392
  if (!sources.has(resolved)) next.push(resolved)
156
393
  } else {
157
- console.warn(`[loader.solidity] Missing import: ${spec} from ${relPath}`)
394
+ warnUnresolved(spec, relPath, r.reason)
158
395
  }
159
396
  }
160
397
  }
161
- await processWave(next)
398
+ wave = next
162
399
  }
163
-
164
- await processWave(entries)
165
400
  return sources
166
401
  }
167
402
 
168
- // Read a foundry.toml/remappings.txt mapping file -> parsed remappings (the file
169
- // itself is not added to sources).
170
- export async function readRemappingsFile(mappingFile) {
171
- const content = await readFile(mappingFile, 'utf8')
172
- if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
173
- 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()
174
433
  }
175
434
 
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.
178
- function assertWithinBase(baseDir, candidate, label) {
179
- if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
180
- const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
181
- if (rel.startsWith('..') || isAbsolute(rel)) {
182
- 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)`)
183
457
  }
458
+ return [...out]
184
459
  }
185
460
 
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.
189
- 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 } = {}) {
190
465
  const baseDir = dirname(resolve(solTxtFile))
191
- const listing = await readFile(solTxtFile, 'utf8')
466
+ const listing = decodeUtf8(await readFile(solTxtFile), solTxtFile)
192
467
  const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
193
468
  if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
194
469
 
195
- let remappings = []
470
+ let mappingFile
196
471
  if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
197
- const mapping = lines.shift()
198
- assertWithinBase(baseDir, mapping, 'Mapping path')
199
- remappings = await readRemappingsFile(join(baseDir, mapping))
472
+ mappingFile = lines.shift()
473
+ assertWithinBase(baseDir, mappingFile, 'Mapping path')
200
474
  }
201
475
 
202
476
  if (!lines.every((line) => line.endsWith('.sol'))) {
@@ -205,6 +479,7 @@ export async function loadSolidity(solTxtFile) {
205
479
 
206
480
  const entries = lines.map((l) => l.replace(/^\.\//u, ''))
207
481
  for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
208
- const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
209
- 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 })
210
485
  }