@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.5
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 +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +32 -18
- package/src/scan.js +79 -43
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
// Foundry configuration for the Solidity loader: foundry.toml profiles (with `extends`) and the
|
|
2
|
+
// remappings `forge build` hands solc -- the `FOUNDRY_REMAPPINGS`/`DAPP_REMAPPINGS` env var,
|
|
3
|
+
// remappings.txt, the profile's `remappings`, those of every dependency that is itself a Foundry
|
|
4
|
+
// project, and the ones forge auto-detects under the `libs` dirs (global, plus per-dependency
|
|
5
|
+
// contexts for an alias two dependencies map differently). A port of foundry v1.8.3's
|
|
6
|
+
// `RemappingsProvider` (crates/config/src/providers/remappings.rs) and foundry-compilers'
|
|
7
|
+
// `Remapping::find_many_with_context` (artifacts/solc/src/remappings/find.rs), including the
|
|
8
|
+
// `forge build` pass that drops aliases of the project's own src/test/script dirs.
|
|
9
|
+
//
|
|
10
|
+
// Paths are absolute POSIX strings internally, compared the way Rust compares `Path`s (by
|
|
11
|
+
// component: a trailing `/` doesn't count), and returned relative to the project root. Not read:
|
|
12
|
+
// the global ~/.foundry/foundry.toml, `FOUNDRY_CONFIG`, and the FOUNDRY_*/DAPP_* overrides of
|
|
13
|
+
// other keys (`FOUNDRY_PROFILE` and the remapping env vars are). The project is read through a
|
|
14
|
+
// `host` (@exodus/stasis-core/host), the disk's by default.
|
|
15
|
+
|
|
16
|
+
import { posix, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
19
|
+
import { toPosix } from '@exodus/stasis-core/util'
|
|
20
|
+
import { isDir } from '../resolve-typescript.js'
|
|
21
|
+
import { projectOwnership, projectRelative, readUtf8OrNull, realpathOrNull } from './solidity-ownership.js'
|
|
22
|
+
import { isTomlTable, readToml } from './toml.js'
|
|
23
|
+
|
|
24
|
+
export const FOUNDRY_TOML = 'foundry.toml'
|
|
25
|
+
export const REMAPPINGS_TXT = 'remappings.txt'
|
|
26
|
+
|
|
27
|
+
// --- Path helpers (Rust `Path` semantics on POSIX strings) --------------------------------
|
|
28
|
+
|
|
29
|
+
const isAbs = (p) => p.startsWith('/')
|
|
30
|
+
const normalComps = (p) => p.split('/').filter((c) => c !== '' && c !== '.')
|
|
31
|
+
const compCount = (p) => normalComps(p).length + (isAbs(p) ? 1 : 0)
|
|
32
|
+
const fileName = (p) => normalComps(p).at(-1) ?? null
|
|
33
|
+
const pathKey = (p) => (isAbs(p) ? '/' : '') + normalComps(p).join('/')
|
|
34
|
+
const pathEq = (a, b) => pathKey(a) === pathKey(b)
|
|
35
|
+
// `Path::ends_with` with a relative suffix: the last components match.
|
|
36
|
+
const pathEndsWith = (p, suffix) => {
|
|
37
|
+
const a = normalComps(p)
|
|
38
|
+
const b = normalComps(suffix)
|
|
39
|
+
return b.length <= a.length && b.every((c, i) => a[a.length - b.length + i] === c)
|
|
40
|
+
}
|
|
41
|
+
// `Path::strip_prefix`: the remaining components (no trailing `/`), or null.
|
|
42
|
+
function stripPrefix(p, base) {
|
|
43
|
+
if (isAbs(p) !== isAbs(base)) return null
|
|
44
|
+
const a = normalComps(p)
|
|
45
|
+
const b = normalComps(base)
|
|
46
|
+
if (b.length > a.length || !b.every((c, i) => a[i] === c)) return null
|
|
47
|
+
return a.slice(b.length).join('/')
|
|
48
|
+
}
|
|
49
|
+
const pathStartsWith = (p, base) => stripPrefix(p, base) !== null
|
|
50
|
+
// `PathBuf::join`: an absolute `p` replaces; otherwise appended with one separator.
|
|
51
|
+
const rustJoin = (base, p) => (isAbs(p) ? p : base.endsWith('/') ? `${base}${p}` : `${base}/${p}`)
|
|
52
|
+
const parentOf = (p) => {
|
|
53
|
+
const c = normalComps(p)
|
|
54
|
+
if (c.length === 0) return null
|
|
55
|
+
return (isAbs(p) ? '/' : '') + c.slice(0, -1).join('/')
|
|
56
|
+
}
|
|
57
|
+
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0)
|
|
58
|
+
// `Ord for Path`: component by component.
|
|
59
|
+
function cmpPath(a, b) {
|
|
60
|
+
if (isAbs(a) !== isAbs(b)) return isAbs(a) ? -1 : 1
|
|
61
|
+
const x = normalComps(a)
|
|
62
|
+
const y = normalComps(b)
|
|
63
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) {
|
|
64
|
+
const c = cmpStr(x[i], y[i])
|
|
65
|
+
if (c !== 0) return c
|
|
66
|
+
}
|
|
67
|
+
return x.length - y.length
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canonicalize(p, host) {
|
|
71
|
+
const real = realpathOrNull(p, host)
|
|
72
|
+
return real === null ? null : toPosix(real)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// How messages name a file of the project at `root` (absolute POSIX, read through `host`): from the
|
|
76
|
+
// root, by its lexical or its canonical path, else as given.
|
|
77
|
+
export function shownFrom(root, host = diskHost, canonicalRoot = canonicalize(root, host) ?? root) {
|
|
78
|
+
return (abs) => stripPrefix(abs, root) || stripPrefix(abs, canonicalRoot) || abs
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isSymlinkPath = (p, host) => {
|
|
82
|
+
try {
|
|
83
|
+
return host.readlink(p) !== null
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A dir's entries with their (symlink-followed) kind, in the filesystem's own order where the host
|
|
90
|
+
// has one (readdirUnsorted): forge walks `read_dir` unsorted, and where two packages share a lib
|
|
91
|
+
// window the first one listed decides a remapping. An entry whose symlink doesn't resolve is dropped.
|
|
92
|
+
function listDir(dir, host) {
|
|
93
|
+
let entries
|
|
94
|
+
try {
|
|
95
|
+
entries = host.readdirUnsorted ? host.readdirUnsorted(dir) : host.readdir(dir)
|
|
96
|
+
} catch {
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
const out = []
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
const path = rustJoin(dir, e.name)
|
|
102
|
+
const kind = e.isSymbolicLink() ? host.stat(path) : e
|
|
103
|
+
if (kind === null) continue
|
|
104
|
+
out.push({ path, name: e.name, isFile: kind.isFile(), isDir: kind.isDirectory(), isSymlink: e.isSymbolicLink() })
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The auto-detection's view of a dir: hidden entries skipped.
|
|
110
|
+
const readDir = (dir, host) => listDir(dir, host).filter((e) => !e.name.startsWith('.'))
|
|
111
|
+
|
|
112
|
+
// --- Remapping values ----------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// Rust's `str::trim`: Unicode White_Space only, so a byte-order mark (U+FEFF, which JS's `trim`
|
|
115
|
+
// takes) stays, as it does for forge.
|
|
116
|
+
const rustTrim = (s) => s.replaceAll(/^\p{White_Space}+|\p{White_Space}+$/gu, '')
|
|
117
|
+
|
|
118
|
+
// `[context:]name=path`, as forge (`Remapping::from_str`) and solc split it: at the first `=`, then
|
|
119
|
+
// the first `:` before it. An empty context is global; an empty name or path is invalid (null),
|
|
120
|
+
// but for solc (`emptyPath`) only an empty name is: `x/=` maps `x/A.sol` to `A.sol`.
|
|
121
|
+
function parseRemapping(entry, { emptyPath = false } = {}) {
|
|
122
|
+
const eq = entry.indexOf('=')
|
|
123
|
+
if (eq === -1) return null
|
|
124
|
+
let name = entry.slice(0, eq)
|
|
125
|
+
const path = entry.slice(eq + 1)
|
|
126
|
+
let context = null
|
|
127
|
+
const colon = name.indexOf(':')
|
|
128
|
+
if (colon !== -1) {
|
|
129
|
+
context = name.slice(0, colon)
|
|
130
|
+
name = name.slice(colon + 1)
|
|
131
|
+
}
|
|
132
|
+
if (rustTrim(name) === '' || (!emptyPath && rustTrim(path) === '')) return null
|
|
133
|
+
if (context !== null && rustTrim(context) === '') context = null
|
|
134
|
+
return { context, name, path }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// What an invalid remapping should have been; errors name where one is, never its text, which may
|
|
138
|
+
// be anything (a file named as a mapping by mistake, a secret included).
|
|
139
|
+
const REMAPPING_FORM = 'expected [context:]prefix=target'
|
|
140
|
+
|
|
141
|
+
// A remappings.txt / env var body: one remapping per non-blank (trimmed) line. A line that isn't
|
|
142
|
+
// one throws, naming `label` (the file or variable) and the line, as forge and solc refuse the
|
|
143
|
+
// file. `emptyPath`: see parseRemapping.
|
|
144
|
+
export function parseRemappingLines(text, { label = 'remappings', emptyPath = false } = {}) {
|
|
145
|
+
const out = []
|
|
146
|
+
text.split('\n').forEach((raw, i) => {
|
|
147
|
+
const line = rustTrim(raw)
|
|
148
|
+
if (line === '') return
|
|
149
|
+
const r = parseRemapping(line, { emptyPath })
|
|
150
|
+
if (r === null) throw new Error(`${label}:${i + 1}: invalid remapping, ${REMAPPING_FORM}`)
|
|
151
|
+
out.push(r)
|
|
152
|
+
})
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A foundry.toml's `remappings` value, parsed. One forge rejects -- not an array of strings, or an
|
|
157
|
+
// entry that isn't `[context:]name=path` -- throws, naming `file` when given and the entry (from 1).
|
|
158
|
+
function configRemappings(value, file) {
|
|
159
|
+
const where = `${file === null ? '' : `${file}: `}\`remappings\``
|
|
160
|
+
if (!Array.isArray(value)) throw new Error(`${where} is not an array of strings`)
|
|
161
|
+
return value.map((entry, i) => {
|
|
162
|
+
if (typeof entry !== 'string') throw new Error(`${where} entry ${i + 1} is not a string`)
|
|
163
|
+
const r = parseRemapping(entry)
|
|
164
|
+
if (r === null) throw new Error(`${where} entry ${i + 1}: invalid remapping, ${REMAPPING_FORM}`)
|
|
165
|
+
return r
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// A config forge refuses for its settings (a missing or nested `extends`, colliding keys) or that a
|
|
170
|
+
// dependency may not read (a link out of it): forge skips such a dependency's config, and so does
|
|
171
|
+
// loadNestedConfig. Anything else wrong with a config -- text that isn't TOML, an invalid
|
|
172
|
+
// remapping -- is another error, and fatal.
|
|
173
|
+
class ConfigRefused extends Error {}
|
|
174
|
+
|
|
175
|
+
// Forge's trailing `/` on a remapping's name and path, unless they end in `/` or `.sol`.
|
|
176
|
+
const withSlash = (s) => (s.endsWith('/') || s.endsWith('.sol') ? s : `${s}/`)
|
|
177
|
+
|
|
178
|
+
// A remapping as forge hands it to solc, in the loader's `{ context, prefix, target }` shape:
|
|
179
|
+
// `forge-std=lib/forge-std/src` is `forge-std/=lib/forge-std/src/`.
|
|
180
|
+
export const toSolcRemapping = (r) => ({ context: r.context, prefix: withSlash(r.name), target: withSlash(r.path) })
|
|
181
|
+
|
|
182
|
+
// The profile forge selects: FOUNDRY_PROFILE, else `default`. Profile names are case-insensitive
|
|
183
|
+
// (figment's `Profile`), so they are compared lowercased.
|
|
184
|
+
export const foundryProfile = (env) => (env.FOUNDRY_PROFILE || 'default').toLowerCase()
|
|
185
|
+
|
|
186
|
+
// `RelativeRemappingPathBuf::with_root`.
|
|
187
|
+
function withRoot(parent, path) {
|
|
188
|
+
const rest = stripPrefix(path, parent)
|
|
189
|
+
if (rest !== null) return { parent, path: rest }
|
|
190
|
+
if (isAbs(path)) return { parent: null, path }
|
|
191
|
+
return { parent, path }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// `RelativeRemapping::new(remapping, root)`.
|
|
195
|
+
function toRelative(r, root) {
|
|
196
|
+
return {
|
|
197
|
+
context: r.context === null ? null : withRoot(root, r.context).path,
|
|
198
|
+
name: r.name,
|
|
199
|
+
path: withRoot(root, r.path),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `From<RelativeRemapping> for Remapping`: the path joined back onto its parent, and a trailing
|
|
204
|
+
// `/` on name and path unless they end in `/` or `.sol`.
|
|
205
|
+
function fromRelative(rr) {
|
|
206
|
+
const { parent, path } = rr.path
|
|
207
|
+
const joined = isAbs(path) || parent === null ? path : rustJoin(parent, path)
|
|
208
|
+
return { context: rr.context, name: withSlash(rr.name), path: withSlash(joined) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `relative_remapping_preserving_context_boundary`: relative to `root`, keeping a context's
|
|
212
|
+
// trailing `/` (it bounds the directory the context names).
|
|
213
|
+
function relativePreservingBoundary(r, root) {
|
|
214
|
+
const rr = toRelative(r, root)
|
|
215
|
+
if (r.context?.endsWith('/') && rr.context !== null) rr.context = withTrailing(rr.context)
|
|
216
|
+
return rr
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// `Display for RelativeRemapping`.
|
|
220
|
+
function displayRelative(rr) {
|
|
221
|
+
const s = `${rr.context === null ? '' : `${rr.context}:`}${rr.name}=${rr.path.path}`
|
|
222
|
+
return withSlash(s)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// `RelativeRemapping` equality (paths by component).
|
|
226
|
+
const relKey = (rr) => `${rr.context}\0${rr.name}\0${rr.path.parent === null ? '\u0001' : pathKey(rr.path.parent)}\0${pathKey(rr.path.path)}`
|
|
227
|
+
|
|
228
|
+
// `Remappings`: a list that only takes an alias not already claimed (in the same context) by an
|
|
229
|
+
// equal or shorter one, and never an alias of the project's own src/test/script dirs.
|
|
230
|
+
class Remappings {
|
|
231
|
+
constructor(remappings = [], projectPaths = []) {
|
|
232
|
+
this.remappings = remappings
|
|
233
|
+
this.projectPaths = projectPaths
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
push(r) {
|
|
237
|
+
if (r.name.endsWith('.sol') && !r.path.endsWith('.sol')) return
|
|
238
|
+
const conflicting = this.remappings.some((e) => {
|
|
239
|
+
if (r.name.endsWith('.sol')) return e.name === r.name && e.context === r.context && e.path === r.path
|
|
240
|
+
return r.name.startsWith(withTrailing(e.name)) && e.context === r.context
|
|
241
|
+
})
|
|
242
|
+
if (conflicting) return
|
|
243
|
+
if (this.projectPaths.some((p) => p.toLowerCase() === r.name.toLowerCase())) return
|
|
244
|
+
this.remappings.push(r)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// First of each (context, name).
|
|
248
|
+
intoInner() {
|
|
249
|
+
const seen = new Set()
|
|
250
|
+
return this.remappings.filter((r) => {
|
|
251
|
+
const key = `${r.context}\0${r.name}`
|
|
252
|
+
if (seen.has(key)) return false
|
|
253
|
+
seen.add(key)
|
|
254
|
+
return true
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const withTrailing = (s) => (s.endsWith('/') ? s : `${s}/`)
|
|
260
|
+
const trimSlashes = (s) => s.replace(/\/+$/u, '')
|
|
261
|
+
|
|
262
|
+
// `remapping_name_is_prefix`: `prefix` names `name` or a parent namespace of it.
|
|
263
|
+
function nameIsPrefix(prefix, name) {
|
|
264
|
+
const p = trimSlashes(prefix)
|
|
265
|
+
const n = trimSlashes(name)
|
|
266
|
+
return p === n || (n.startsWith(p) && n.slice(p.length).startsWith('/'))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Auto-detection (foundry-compilers find.rs) ---------------------------------------------
|
|
270
|
+
|
|
271
|
+
const SRC_DIR = 'src'
|
|
272
|
+
const JS_SRC_DIR = 'contracts'
|
|
273
|
+
const isSourceDir = (p) => [SRC_DIR, JS_SRC_DIR].includes(fileName(p))
|
|
274
|
+
const isLibName = (name) => name === 'lib' || name === 'node_modules'
|
|
275
|
+
const isLibDir = (p) => isLibName(fileName(p))
|
|
276
|
+
const noRecurse = (name) => name === 'tests' || name === 'test' || name === 'demo'
|
|
277
|
+
|
|
278
|
+
function dirDistance(root, current) {
|
|
279
|
+
const rest = stripPrefix(current, root)
|
|
280
|
+
return rest === null ? 0 : normalComps(rest).length
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The window a dir under the lib dir `root` belongs to: `root`'s child on the way to `current`.
|
|
284
|
+
// (Upstream loops `while !is_lib_dir(next) || !next.ends_with("contracts")`, which always holds on
|
|
285
|
+
// the first component.)
|
|
286
|
+
function nextNestedWindow(root, current) {
|
|
287
|
+
if (!isLibDir(root)) return root
|
|
288
|
+
const first = normalComps(stripPrefix(current, root) ?? '')[0]
|
|
289
|
+
return first === undefined ? root : rustJoin(root, first)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function lastNestedSourceDir(root, dir) {
|
|
293
|
+
if (isSourceDir(dir)) return dir
|
|
294
|
+
let p = dir
|
|
295
|
+
for (let parent = parentOf(p); parent !== null; parent = parentOf(p)) {
|
|
296
|
+
if (pathEq(parent, root)) return root
|
|
297
|
+
if (isSourceDir(parent)) return parent
|
|
298
|
+
p = parent
|
|
299
|
+
}
|
|
300
|
+
return root
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const endsWithJsSource = (c) => fileName(c.sourceDir) === JS_SRC_DIR || pathEndsWith(c.sourceDir, 'contracts/src')
|
|
304
|
+
|
|
305
|
+
function mergeOnSameLevel(candidates, currentDir, level, windowStart, insideNodeModules) {
|
|
306
|
+
// A single `src` candidate wins outright.
|
|
307
|
+
const srcs = candidates.filter((c) => fileName(c.sourceDir) === SRC_DIR)
|
|
308
|
+
if (srcs.length === 1) {
|
|
309
|
+
candidates.splice(0, candidates.length, srcs[0])
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
// Else the current dir absorbs the candidates of its level (`current/{auth,tokens}/*.sol`).
|
|
313
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
314
|
+
if (candidates[i].level === level) candidates.splice(i, 1)
|
|
315
|
+
}
|
|
316
|
+
const sourceDir = insideNodeModules ? windowStart : currentDir
|
|
317
|
+
// `<dep>/src/lib/` mistaken for a package of its own.
|
|
318
|
+
if (level > 0 && pathEq(sourceDir, windowStart) && (isSourceDir(sourceDir) || isLibDir(sourceDir))) return
|
|
319
|
+
candidates.push({ windowStart, sourceDir, level })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Candidates below `currentDir`: a window opens at each `lib`/`node_modules` barrier, and a dir
|
|
323
|
+
// holding `.sol` files is a source dir of the window it lies in. Symlinked dirs are followed,
|
|
324
|
+
// except back into the current traversal path.
|
|
325
|
+
function findRemappingCandidates(currentDir, open, level, insideNodeModules, visited, host) {
|
|
326
|
+
let isCandidate = false
|
|
327
|
+
let current
|
|
328
|
+
const search = []
|
|
329
|
+
for (const e of readDir(currentDir, host)) {
|
|
330
|
+
if (!isCandidate && e.isFile && e.name.endsWith('.sol')) {
|
|
331
|
+
isCandidate = true
|
|
332
|
+
} else if (e.isDir) {
|
|
333
|
+
let seen = visited
|
|
334
|
+
if (e.isSymlink) {
|
|
335
|
+
const target = canonicalize(e.path, host)
|
|
336
|
+
if (target !== null) {
|
|
337
|
+
current ??= canonicalize(currentDir, host)
|
|
338
|
+
if (visited.has(target) || (current !== null && pathStartsWith(current, target))) continue
|
|
339
|
+
seen = new Set(visited).add(target)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!noRecurse(e.name)) search.push([e, seen])
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const candidates = []
|
|
347
|
+
for (const [{ path, name }, seen] of search) {
|
|
348
|
+
candidates.push(...(isLibName(name)
|
|
349
|
+
? findRemappingCandidates(path, path, level + 1, insideNodeModules, seen, host)
|
|
350
|
+
: findRemappingCandidates(path, open, level, insideNodeModules, seen, host)))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const windowStart = nextNestedWindow(open, currentDir)
|
|
354
|
+
if (isCandidate || candidates.filter((c) => c.level === level && pathEq(c.windowStart, windowStart)).length > 1) {
|
|
355
|
+
mergeOnSameLevel(candidates, currentDir, level, windowStart, insideNodeModules)
|
|
356
|
+
} else {
|
|
357
|
+
// A single nested candidate: `current/nested/contracts/c.sol` maps to `current`.
|
|
358
|
+
const c = candidates.find((x) => x.level === level)
|
|
359
|
+
if (c) {
|
|
360
|
+
const distance = dirDistance(c.windowStart, c.sourceDir)
|
|
361
|
+
if (distance > 1 && endsWithJsSource(c)) c.sourceDir = windowStart
|
|
362
|
+
else if (!isSourceDir(c.sourceDir) && !pathEq(c.sourceDir, c.windowStart)) c.sourceDir = lastNestedSourceDir(open, c.sourceDir)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return candidates
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Prefer the shorter path, then one ending in `src`.
|
|
369
|
+
function insertPrioritized(map, key, path) {
|
|
370
|
+
const existing = map.get(key)
|
|
371
|
+
if (existing === undefined || compCount(existing) > compCount(path) || (fileName(path) === SRC_DIR && fileName(existing) !== SRC_DIR)) {
|
|
372
|
+
map.set(key, path)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The dependency owning a nested package window: the path before the last `lib`/`node_modules`
|
|
377
|
+
// component below `root` (null at the top level).
|
|
378
|
+
function dependencyOwner(root, windowStart) {
|
|
379
|
+
const rest = stripPrefix(windowStart, root)
|
|
380
|
+
if (rest === null) return null
|
|
381
|
+
const parts = normalComps(rest)
|
|
382
|
+
const barrier = parts.findLastIndex(isLibName)
|
|
383
|
+
return barrier > 0 ? parts.slice(0, barrier).reduce((p, c) => rustJoin(p, c), root) : null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const byContextDepth = (a, b) => {
|
|
387
|
+
const x = a.context ?? ''
|
|
388
|
+
const y = b.context ?? ''
|
|
389
|
+
return compCount(y) - compCount(x) || cmpStr(x, y)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A map's entries, sorted by key with `cmp`.
|
|
393
|
+
const sortedEntries = (map, cmp) => [...map].toSorted(([a], [b]) => cmp(a, b))
|
|
394
|
+
|
|
395
|
+
// `Remapping::find_many_with_context(dir)`: `{ global, contextual }` remappings for the packages
|
|
396
|
+
// under a lib dir, `contextual` keyed by the dependency whose own lib dir holds the package.
|
|
397
|
+
export function findRemappingsWithContext(dir, host = diskHost) {
|
|
398
|
+
const insideNodeModules = fileName(dir) === 'node_modules'
|
|
399
|
+
const candidates = readDir(dir, host)
|
|
400
|
+
.filter((e) => e.isDir)
|
|
401
|
+
.flatMap((e) => findRemappingCandidates(e.path, e.path, 0, insideNodeModules, new Set(), host))
|
|
402
|
+
.toSorted((a, b) => cmpPath(a.sourceDir, b.sourceDir))
|
|
403
|
+
|
|
404
|
+
const global = new Map()
|
|
405
|
+
const contextual = new Map()
|
|
406
|
+
for (const c of candidates) {
|
|
407
|
+
const name = fileName(c.windowStart)
|
|
408
|
+
if (name === null) continue
|
|
409
|
+
const key = `${name}/`
|
|
410
|
+
const owner = dependencyOwner(dir, c.windowStart)
|
|
411
|
+
if (owner !== null) {
|
|
412
|
+
if (!contextual.has(owner)) contextual.set(owner, new Map())
|
|
413
|
+
insertPrioritized(contextual.get(owner), key, c.sourceDir)
|
|
414
|
+
}
|
|
415
|
+
insertPrioritized(global, key, c.sourceDir)
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
global: sortedEntries(global, cmpStr).map(([name, path]) => ({ context: null, name, path: `${path}/` })),
|
|
419
|
+
contextual: sortedEntries(contextual, cmpPath)
|
|
420
|
+
.flatMap(([owner, map]) => sortedEntries(map, cmpStr).map(([name, path]) => ({ context: `${owner}/`, name, path: `${path}/` })))
|
|
421
|
+
.toSorted(byContextDepth),
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- foundry.toml ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
const snakeCase = (k) => k.replaceAll(/([a-z0-9])([A-Z])/gu, '$1_$2').replaceAll('-', '_').toLowerCase()
|
|
428
|
+
|
|
429
|
+
// Top-level tables that are sections of their own, not (legacy) profiles (`Config::STANDALONE_SECTIONS`).
|
|
430
|
+
const STANDALONE_SECTIONS = new Set([
|
|
431
|
+
'profile', 'external', 'rpc_endpoints', 'etherscan', 'fmt', 'lint', 'doc', 'fuzz', 'invariant', 'symbolic',
|
|
432
|
+
'coverage', 'mutation', 'tracing', 'labels', 'dependencies', 'soldeer', 'vyper', 'bind_json',
|
|
433
|
+
])
|
|
434
|
+
|
|
435
|
+
// foundry.toml -> `{ profiles, topLevel }`. `profiles` is Map<profile, Map<key, value>> (profile
|
|
436
|
+
// names lowercased, keys snake_cased as forge does) from the `[profile.<name>]` tables and the
|
|
437
|
+
// legacy top-level `[<name>]` ones forge still reads (not for `extends`), the former winning key
|
|
438
|
+
// by key; a profile's sub-tables are its values like any other (`extends`, `fuzz`: forge compares
|
|
439
|
+
// them all for a `no-collision` extends). `topLevel` holds the values set outside any table (forge
|
|
440
|
+
// rejects those; a `--mapping` file may list its `remappings` there). Throws a TomlError naming
|
|
441
|
+
// `file` on text that isn't TOML, as forge refuses the file.
|
|
442
|
+
function parseFoundryToml(text, file = null) {
|
|
443
|
+
const current = new Map()
|
|
444
|
+
const legacy = new Map()
|
|
445
|
+
const topLevel = new Map()
|
|
446
|
+
const read = (map, name, table) => {
|
|
447
|
+
const profile = name.toLowerCase()
|
|
448
|
+
const dict = map.get(profile) ?? map.set(profile, new Map()).get(profile)
|
|
449
|
+
for (const [key, value] of Object.entries(table)) {
|
|
450
|
+
const k = snakeCase(key)
|
|
451
|
+
if (k !== 'extends' || map === current) dict.set(k, value) // forge reads `extends` from `[profile.<name>]` only
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
for (const [key, value] of Object.entries(readToml(text, file))) {
|
|
455
|
+
if (!isTomlTable(value)) topLevel.set(snakeCase(key), value)
|
|
456
|
+
else if (key === 'profile') {
|
|
457
|
+
for (const [name, table] of Object.entries(value)) if (isTomlTable(table)) read(current, name, table)
|
|
458
|
+
} else if (!STANDALONE_SECTIONS.has(key)) read(legacy, key, value)
|
|
459
|
+
}
|
|
460
|
+
const profiles = new Map([...legacy].map(([name, dict]) => [name, new Map(dict)]))
|
|
461
|
+
for (const [name, dict] of current) profiles.set(name, new Map([...(profiles.get(name) ?? []), ...dict]))
|
|
462
|
+
return { profiles, topLevel }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Figment's merge of an `extends` base under the local file: local keys win, and with the
|
|
466
|
+
// default `extend-arrays` strategy an array set on both sides is the base's followed by the local's.
|
|
467
|
+
function mergeExtended(base, local, strategy) {
|
|
468
|
+
const out = new Map([...base].map(([p, dict]) => [p, new Map(dict)]))
|
|
469
|
+
for (const [p, dict] of local) {
|
|
470
|
+
if (!out.has(p)) out.set(p, new Map())
|
|
471
|
+
const merged = out.get(p)
|
|
472
|
+
for (const [k, v] of dict) {
|
|
473
|
+
const prev = merged.get(k)
|
|
474
|
+
merged.set(k, strategy === 'extend-arrays' && Array.isArray(prev) && Array.isArray(v) ? [...prev, ...v] : v)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// forge's `Extends`: a path, or `{ path, strategy? }`.
|
|
481
|
+
const EXTEND_STRATEGIES = new Set(['extend-arrays', 'replace-arrays', 'no-collision'])
|
|
482
|
+
const isExtends = (v) => typeof v === 'string'
|
|
483
|
+
|| (isTomlTable(v) && typeof v.path === 'string' && (v.strategy === undefined || EXTEND_STRATEGIES.has(v.strategy)))
|
|
484
|
+
|
|
485
|
+
// A foundry.toml's profiles, with the selected profile's `extends` base merged in (forge's
|
|
486
|
+
// `TomlFileProvider`). `files` lists what was read; `topLevel` is the file's own (see
|
|
487
|
+
// parseFoundryToml). Throws where forge refuses the config, and where `refused` (a dependency's
|
|
488
|
+
// config: see findNestedFoundryRemappings) gives a reason not to read the file or its base: a
|
|
489
|
+
// dependency's config may not read the project's files. Messages name files `show(file)`.
|
|
490
|
+
function readFoundryProfiles(file, profile, { refused = () => null, show, host }) {
|
|
491
|
+
const name = show(file)
|
|
492
|
+
const refusal = refused(file)
|
|
493
|
+
if (refusal) throw new ConfigRefused(`${name}: refusing to read it: ${refusal}`)
|
|
494
|
+
const text = readUtf8OrNull(file, name, host)
|
|
495
|
+
if (text === null) return { profiles: new Map(), topLevel: new Map(), files: [] }
|
|
496
|
+
let { profiles, topLevel } = parseFoundryToml(text, name)
|
|
497
|
+
const files = [file]
|
|
498
|
+
const ext = profiles.get(profile)?.get('extends')
|
|
499
|
+
if (ext !== undefined) {
|
|
500
|
+
if (!isExtends(ext)) throw new Error(`${name}: \`extends\` must be a path, or a table with a \`path\` and an optional \`strategy\` (${[...EXTEND_STRATEGIES].join(', ')})`)
|
|
501
|
+
const { path: extPath, strategy = 'extend-arrays' } = typeof ext === 'string' ? { path: ext } : ext
|
|
502
|
+
// Joined as forge joins it, not normalized: the read resolves a `..` after a symlink the way
|
|
503
|
+
// forge's does (from where the link leads), not textually.
|
|
504
|
+
const baseFile = rustJoin(posix.dirname(file), extPath)
|
|
505
|
+
const baseRefusal = refused(baseFile)
|
|
506
|
+
if (baseRefusal) throw new ConfigRefused(`${name}: refusing to extend ${extPath}: ${baseRefusal}`)
|
|
507
|
+
const baseName = show(baseFile)
|
|
508
|
+
const baseText = readUtf8OrNull(baseFile, baseName, host)
|
|
509
|
+
if (baseText === null) throw new ConfigRefused(`${name}: the inherited config file does not exist: ${extPath}`)
|
|
510
|
+
const base = parseFoundryToml(baseText, baseName).profiles
|
|
511
|
+
if (base.get(profile)?.has('extends')) {
|
|
512
|
+
throw new ConfigRefused(`${name}: nested inheritance is not allowed (${extPath} has an 'extends' field in profile '${profile}')`)
|
|
513
|
+
}
|
|
514
|
+
if (strategy === 'no-collision') {
|
|
515
|
+
const collisions = [...(profiles.get(profile)?.keys() ?? [])].filter((k) => k !== 'extends' && base.get(profile)?.has(k))
|
|
516
|
+
if (collisions.length > 0) throw new ConfigRefused(`${name}: key collision in profile '${profile}' when extending ${extPath}: ${collisions.join(', ')}`)
|
|
517
|
+
}
|
|
518
|
+
profiles = mergeExtended(base, profiles, strategy)
|
|
519
|
+
files.push(baseFile)
|
|
520
|
+
}
|
|
521
|
+
return { profiles, topLevel, files }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// `[profile.default]` overlaid with the selected profile (a missing selected profile falls back to
|
|
525
|
+
// the default, as forge does for dependency configs).
|
|
526
|
+
function selectProfile(profiles, profile) {
|
|
527
|
+
const dict = new Map(profiles.get('default') ?? [])
|
|
528
|
+
if (profile !== 'default') for (const [k, v] of profiles.get(profile) ?? []) dict.set(k, v)
|
|
529
|
+
return dict
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// The `remappings` a foundry.toml's profiles set for `profile` (`[profile.default]` overlaid by
|
|
533
|
+
// it), else the file's top-level `remappings` (a mapping file written for stasis), as written; an
|
|
534
|
+
// invalid one throws (configRemappings, naming `file`).
|
|
535
|
+
function profileRemappings({ profiles, topLevel }, profile, file = null) {
|
|
536
|
+
const value = selectProfile(profiles, profile).get('remappings') ?? topLevel.get('remappings')
|
|
537
|
+
return value === undefined ? [] : configRemappings(value, file)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// A foundry.toml text's own remappings for `profile` (see profileRemappings).
|
|
541
|
+
export function foundryTomlRemappings(text, profile = 'default') {
|
|
542
|
+
return profileRemappings(parseFoundryToml(text), profile)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// The same for a foundry.toml file, with its `extends` base: what `--mapping=foundry.toml` takes.
|
|
546
|
+
// `files` lists what was read; `profiled` whether the selected `profile` is one of the file's.
|
|
547
|
+
// Messages name files `show(file)`.
|
|
548
|
+
export function readFoundryTomlRemappings(file, profile, { show, host = diskHost }) {
|
|
549
|
+
const abs = toPosix(resolve(file))
|
|
550
|
+
const read = readFoundryProfiles(abs, profile, { show, host })
|
|
551
|
+
return { remappings: profileRemappings(read, profile, show(abs)), files: read.files, profiled: hasProfile(read.profiles, profile) }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Whether the selected `profile` is one of `profiles` (not the default, which always applies).
|
|
555
|
+
const hasProfile = (profiles, profile) => profile !== 'default' && profiles.has(profile)
|
|
556
|
+
|
|
557
|
+
// hasProfile, for the root foundry.toml: one that isn't there is warned about (forge uses
|
|
558
|
+
// `[profile.default]` for it).
|
|
559
|
+
function profileApplies(profiles, profile) {
|
|
560
|
+
if (profile !== 'default' && !profiles.has(profile)) {
|
|
561
|
+
console.warn(`[loader.solidity] FOUNDRY_PROFILE=${profile} is not a profile in foundry.toml; using [profile.default]`)
|
|
562
|
+
}
|
|
563
|
+
return hasProfile(profiles, profile)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// `ProjectPathsConfig::find_source_dir`: `src` unless only `contracts` exists.
|
|
567
|
+
const findSourceDir = (root, host) => (isDir(rustJoin(root, 'src'), host) || !isDir(rustJoin(root, JS_SRC_DIR), host) ? 'src' : JS_SRC_DIR)
|
|
568
|
+
|
|
569
|
+
// `DappHardhatDirProvider`: `lib` and/or `node_modules`, whichever exist (`lib` when neither).
|
|
570
|
+
function detectLibs(root, host) {
|
|
571
|
+
const nm = isDir(rustJoin(root, 'node_modules'), host)
|
|
572
|
+
const lib = isDir(rustJoin(root, 'lib'), host)
|
|
573
|
+
if (!nm) return ['lib']
|
|
574
|
+
return lib ? ['lib', 'node_modules'] : ['node_modules']
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// The selected profile's settings for a Foundry project at `root` (absolute POSIX), defaults
|
|
578
|
+
// filled in the way forge fills them. `remappings` are the profile's own, unnormalized; an invalid
|
|
579
|
+
// one throws (configRemappings). `refused`, `show` (from the root by default), `host`: see
|
|
580
|
+
// readFoundryProfiles.
|
|
581
|
+
function loadFoundryConfig(root, profile, { refused, host, show = shownFrom(root, host) }) {
|
|
582
|
+
const file = rustJoin(root, FOUNDRY_TOML)
|
|
583
|
+
const name = show(file)
|
|
584
|
+
const { profiles, files } = readFoundryProfiles(file, profile, { refused, show, host })
|
|
585
|
+
const dict = selectProfile(profiles, profile)
|
|
586
|
+
// A setting of the wrong type throws, as forge refuses the config: no quiet default.
|
|
587
|
+
const setting = (key, ok, what) => {
|
|
588
|
+
const value = dict.get(key)
|
|
589
|
+
if (value !== undefined && !ok(value)) throw new Error(`${name}: \`${key}\` must be ${what}`)
|
|
590
|
+
return value
|
|
591
|
+
}
|
|
592
|
+
const isString = (v) => typeof v === 'string'
|
|
593
|
+
return {
|
|
594
|
+
profiles,
|
|
595
|
+
files,
|
|
596
|
+
src: setting('src', isString, 'a string') ?? findSourceDir(root, host),
|
|
597
|
+
test: setting('test', isString, 'a string') ?? 'test',
|
|
598
|
+
script: setting('script', isString, 'a string') ?? 'script',
|
|
599
|
+
libs: setting('libs', (v) => Array.isArray(v) && v.every(isString), 'an array of strings') ?? detectLibs(root, host),
|
|
600
|
+
remappings: dict.has('remappings') ? configRemappings(dict.get('remappings'), name) : [],
|
|
601
|
+
autoDetect: setting('auto_detect_remappings', (v) => typeof v === 'boolean', 'a boolean') !== false,
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- The remappings provider ----------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
// `foundry_toml_dir_entries`: `dir` and its direct subdirs (symlinks resolved) that hold a
|
|
608
|
+
// foundry.toml, as `{ canonical, path, isSymlink }`.
|
|
609
|
+
function foundryTomlDirEntries(dir, host) {
|
|
610
|
+
const out = []
|
|
611
|
+
const consider = (path, isSymlink) => {
|
|
612
|
+
if (host.stat(rustJoin(path, FOUNDRY_TOML)) === null) return
|
|
613
|
+
const canonical = canonicalize(path, host)
|
|
614
|
+
if (canonical !== null) out.push({ canonical, path, isSymlink })
|
|
615
|
+
}
|
|
616
|
+
consider(dir, isSymlinkPath(dir, host))
|
|
617
|
+
for (const e of listDir(dir, host)) if (e.isDir) consider(e.path, e.isSymlink)
|
|
618
|
+
return out
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const cmpEntry = (a, b) => cmpPath(a.canonical, b.canonical) || cmpPath(a.path, b.path)
|
|
622
|
+
|
|
623
|
+
function rebaseNested(r, canonical, lexical) {
|
|
624
|
+
const rebase = (v) => {
|
|
625
|
+
const rest = stripPrefix(v, canonical)
|
|
626
|
+
return rest === null ? v : rustJoin(lexical, rest)
|
|
627
|
+
}
|
|
628
|
+
const out = { ...r, path: rebase(r.path) }
|
|
629
|
+
if (r.context !== null) {
|
|
630
|
+
const boundary = r.context.endsWith('/')
|
|
631
|
+
let context
|
|
632
|
+
if (isAbs(r.context)) {
|
|
633
|
+
context = rebase(r.context)
|
|
634
|
+
} else {
|
|
635
|
+
const parts = []
|
|
636
|
+
for (const c of normalComps(rustJoin(lexical, r.context))) {
|
|
637
|
+
if (c === '..') parts.pop()
|
|
638
|
+
else parts.push(c)
|
|
639
|
+
}
|
|
640
|
+
context = `/${parts.join('/')}`
|
|
641
|
+
}
|
|
642
|
+
out.context = boundary ? withTrailing(context) : context
|
|
643
|
+
}
|
|
644
|
+
return out
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A dependency's config as forge's `load_nested_config` reads it: remappings rebased onto its
|
|
648
|
+
// canonical root, its remappings.txt, its src and libs. Null when forge would reject the config,
|
|
649
|
+
// or when `refused` refuses it or its `extends` base (warned: ConfigRefused); a remappings.txt it
|
|
650
|
+
// refuses is skipped (warned). One that isn't TOML or holds an invalid remapping throws: forge
|
|
651
|
+
// refuses a bad remappings.txt line too, and skips a foundry.toml it can't read, which here is an
|
|
652
|
+
// error rather than a config quietly left out. `refused`, `show`: see readFoundryProfiles.
|
|
653
|
+
function loadNestedConfig(canonical, profile, { refused, show, host }) {
|
|
654
|
+
let config
|
|
655
|
+
try {
|
|
656
|
+
config = loadFoundryConfig(canonical, profile, { refused, show, host })
|
|
657
|
+
} catch (err) {
|
|
658
|
+
if (!(err instanceof ConfigRefused)) throw err
|
|
659
|
+
console.warn(`[loader.solidity] Skipping a dependency's config: ${err.message}`)
|
|
660
|
+
return null
|
|
661
|
+
}
|
|
662
|
+
const txt = rustJoin(canonical, REMAPPINGS_TXT)
|
|
663
|
+
const txtName = show(txt)
|
|
664
|
+
const refusal = refused(txt) // (null when nothing is there)
|
|
665
|
+
if (refusal) console.warn(`[loader.solidity] Skipping a dependency's ${txtName}: ${refusal}`)
|
|
666
|
+
const text = refusal ? null : readUtf8OrNull(txt, txtName, host)
|
|
667
|
+
return {
|
|
668
|
+
src: config.src,
|
|
669
|
+
libs: config.libs,
|
|
670
|
+
files: [...config.files, ...(text === null ? [] : [txt])],
|
|
671
|
+
// `sanitized()` roots them, then `Remapping::from` makes the path absolute and slash-terminated.
|
|
672
|
+
remappings: config.remappings.map((r) => fromRelative(relativePreservingBoundary(fromRelative({ ...r, path: { parent: null, path: r.path } }), canonical))),
|
|
673
|
+
fileRemappings: text === null ? [] : parseRemappingLines(text, { label: txtName }),
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// `find_nested_foundry_remappings`: `[lexicalLibPath, remapping, isPackageEntry]` for every
|
|
678
|
+
// dependency (transitively, through each one's own libs) that is a Foundry project. A dependency's
|
|
679
|
+
// config reads only its own files and other dependencies' (by real path: `ownership`, see
|
|
680
|
+
// solidityOwnership), as forge would find them from its lexical path.
|
|
681
|
+
function findNestedFoundryRemappings(root, libPaths, profile, files, ownership, host) {
|
|
682
|
+
const canonicalRoot = canonicalize(root, host) ?? root
|
|
683
|
+
const shown = shownFrom(root, host, canonicalRoot)
|
|
684
|
+
// A dependency's file (a path from its canonical dir) under its lexical path: where the bundle
|
|
685
|
+
// sees it, and how messages name it.
|
|
686
|
+
const lexical = (entry, file) => rustJoin(entry.path, stripPrefix(file, entry.canonical) ?? file)
|
|
687
|
+
// Why the config of the dependency at `entry` may not read `file` (a path from its canonical
|
|
688
|
+
// dir), or null: judged by the path from the root, the lexical one or else the canonical one (an
|
|
689
|
+
// absolute lib, `/proc/self/cwd/...`). It may read its own files and other dependencies'; a
|
|
690
|
+
// dependency outside the root reads nothing, and one a dependency's `libs` named must be a
|
|
691
|
+
// dependency itself (not the project's own dir passed off as one). Nothing there (the OS agrees:
|
|
692
|
+
// solidityOwnership) is left for the read to find missing.
|
|
693
|
+
const refused = (entry) => (file) => {
|
|
694
|
+
const dir = stripPrefix(entry.path, root) ?? stripPrefix(entry.canonical, canonicalRoot)
|
|
695
|
+
if (dir === null) return 'the dependency lies outside the project root'
|
|
696
|
+
if (entry.viaDependency && !ownership.of(dir).dependency) return `${dir}, which a dependency's \`libs\` names, isn't a dependency`
|
|
697
|
+
// `file` as joined under the dependency's dir (an `extends` path unnormalized, for the walk to
|
|
698
|
+
// resolve as the read does); one not under it (an absolute path elsewhere) lies outside it.
|
|
699
|
+
if (!file.startsWith(`${entry.canonical}/`)) return 'it lies outside the dependency'
|
|
700
|
+
const o = ownership.of(`${dir}/${file.slice(entry.canonical.length + 1)}`)
|
|
701
|
+
if (o.reason) return o.reason
|
|
702
|
+
if (o.outside) return `it resolves to ${o.real}, outside the project root`
|
|
703
|
+
if (o.real === null || o.dependency || pathStartsWith(rustJoin(canonicalRoot, o.real), entry.canonical)) return null
|
|
704
|
+
return `it resolves to the project's own ${o.real}`
|
|
705
|
+
}
|
|
706
|
+
const show = (entry) => (file) => shown(lexical(entry, file))
|
|
707
|
+
// A BTreeSet popped in (canonical, path) order.
|
|
708
|
+
const pending = new Map()
|
|
709
|
+
const addPending = (e) => pending.set(`${e.canonical}\0${e.path}`, e)
|
|
710
|
+
for (const lib of libPaths) for (const e of foundryTomlDirEntries(rustJoin(root, lib), host)) addPending(e)
|
|
711
|
+
const seen = new Set([canonicalRoot])
|
|
712
|
+
const configs = new Map()
|
|
713
|
+
const out = []
|
|
714
|
+
while (pending.size > 0) {
|
|
715
|
+
let key
|
|
716
|
+
let entry
|
|
717
|
+
for (const [k, e] of pending) {
|
|
718
|
+
if (entry === undefined || cmpEntry(e, entry) < 0) [key, entry] = [k, e]
|
|
719
|
+
}
|
|
720
|
+
pending.delete(key)
|
|
721
|
+
if (entry.canonical === canonicalRoot) continue
|
|
722
|
+
if (!configs.has(entry.canonical)) {
|
|
723
|
+
const config = loadNestedConfig(entry.canonical, profile, { refused: refused(entry), show: show(entry), host })
|
|
724
|
+
configs.set(entry.canonical, config)
|
|
725
|
+
for (const f of config?.files ?? []) files.add(lexical(entry, f))
|
|
726
|
+
}
|
|
727
|
+
const config = configs.get(entry.canonical)
|
|
728
|
+
if (!config) continue
|
|
729
|
+
for (const r of config.remappings) out.push([entry.path, rebaseNested(r, entry.canonical, entry.path), false])
|
|
730
|
+
for (const r of config.fileRemappings) out.push([entry.path, fromRelative(toRelative(r, entry.path)), false])
|
|
731
|
+
if (!entry.isSymlink && !seen.has(entry.canonical)) {
|
|
732
|
+
seen.add(entry.canonical)
|
|
733
|
+
for (const lib of config.libs) {
|
|
734
|
+
for (const e of foundryTomlDirEntries(rustJoin(entry.path, lib), host)) if (!e.isSymlink) addPending({ ...e, viaDependency: true })
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// A custom (or missing) source dir isn't auto-detected: forge synthesizes `<dep>/=<dep>/<src>/`.
|
|
738
|
+
const standard = ['src', 'contracts', 'lib'].some((s) => pathEq(s, config.src))
|
|
739
|
+
const name = fileName(entry.path)
|
|
740
|
+
if ((!standard || !isDir(rustJoin(entry.canonical, config.src), host)) && name !== null) {
|
|
741
|
+
out.push([entry.path, { context: null, name: `${name}/`, path: withTrailing(rustJoin(entry.path, config.src)) }, true])
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return out
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// `configured_auto_remapping`: an auto-detected alias a dependency's config redirects (its
|
|
748
|
+
// synthesized `<dep>/<src>/` entry) takes that target.
|
|
749
|
+
function configuredAutoRemapping(r, packageEntries) {
|
|
750
|
+
let best = null
|
|
751
|
+
for (const [lib, configured] of packageEntries) {
|
|
752
|
+
if (configured.name !== r.name) continue
|
|
753
|
+
if (r.context !== null && (pathEq(lib, r.context) || !pathStartsWith(lib, r.context))) continue
|
|
754
|
+
let rank
|
|
755
|
+
if (pathStartsWith(r.path, lib)) rank = [0, Number.MAX_SAFE_INTEGER - compCount(lib)]
|
|
756
|
+
else if (r.context !== null && pathStartsWith(lib, r.context) && pathStartsWith(lib, r.path)) rank = [1, compCount(lib)]
|
|
757
|
+
else continue
|
|
758
|
+
// `min_by` rank, then lib path: the first minimum wins.
|
|
759
|
+
const order = best === null ? -1 : (rank[0] - best.rank[0] || rank[1] - best.rank[1] || cmpPath(lib, best.lib))
|
|
760
|
+
if (order < 0) best = { rank, lib, configured }
|
|
761
|
+
}
|
|
762
|
+
return best === null ? r : { ...r, path: best.configured.path }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// `contextual_overlays`: null when an applicable authoritative alias already covers the
|
|
766
|
+
// refinement; else the authoritative aliases below it, re-scoped to the refinement's context.
|
|
767
|
+
function contextualOverlays(authoritative, refinement) {
|
|
768
|
+
const applicable = authoritative.filter((m) => m.context === null || (refinement.context !== null && pathStartsWith(refinement.context, m.context)))
|
|
769
|
+
if (applicable.some((m) => nameIsPrefix(m.name, refinement.name))) return null
|
|
770
|
+
return applicable
|
|
771
|
+
.filter((m) => nameIsPrefix(refinement.name, m.name))
|
|
772
|
+
.toSorted((a, b) => b.name.length - a.name.length)
|
|
773
|
+
.map((m) => ({ ...m, context: refinement.context }))
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// `expand_scoped_contextual_remapping`: a contextual `@scope/=<..>/node_modules/@scope/` becomes
|
|
777
|
+
// one remapping per package in the scope.
|
|
778
|
+
function expandScopedContextual(r, host) {
|
|
779
|
+
const scope = trimSlashes(r.name)
|
|
780
|
+
if (!scope.startsWith('@') || fileName(r.path) !== scope || fileName(parentOf(r.path) ?? '') !== 'node_modules') return [r]
|
|
781
|
+
const packages = listDir(r.path, host)
|
|
782
|
+
.filter((e) => e.isDir)
|
|
783
|
+
.map((e) => ({ context: r.context, name: `${scope}/${e.name}/`, path: `${e.path}/` }))
|
|
784
|
+
.toSorted((a, b) => cmpStr(a.name, b.name))
|
|
785
|
+
return packages.length === 0 ? [r] : packages
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Forge's closest-path choice per alias: fewer components, then `src`, then path order.
|
|
789
|
+
function insertClosest(m, key, path) {
|
|
790
|
+
const existing = m.get(key)
|
|
791
|
+
const srcRank = (p) => (fileName(p) === SRC_DIR ? 0 : 1)
|
|
792
|
+
if (existing === undefined || (compCount(path) - compCount(existing) || srcRank(path) - srcRank(existing) || cmpPath(path, existing)) < 0) {
|
|
793
|
+
m.set(key, path)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// A refinement with the authoritative aliases it overlays, or nothing when one already covers it.
|
|
798
|
+
const withOverlays = (authoritative, r) => {
|
|
799
|
+
const overlays = contextualOverlays(authoritative, r)
|
|
800
|
+
return overlays ? [...overlays, r] : []
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// `RemappingsProvider::get_remappings`: the remappings in the order forge settles them.
|
|
804
|
+
function providerRemappings(root, { userRemappings, libs, autoDetect, profile, files, ownership, host }) {
|
|
805
|
+
const authoritativeUser = userRemappings.map((r) => (r.context === null ? r : { ...r, context: rustJoin(root, r.context) }))
|
|
806
|
+
const all = new Remappings([...userRemappings])
|
|
807
|
+
if (!autoDetect) return all.intoInner()
|
|
808
|
+
|
|
809
|
+
const nested = findNestedFoundryRemappings(root, libs, profile, files, ownership, host)
|
|
810
|
+
const auto = { global: [], contextual: [] }
|
|
811
|
+
for (const lib of libs) {
|
|
812
|
+
const found = findRemappingsWithContext(rustJoin(root, lib), host)
|
|
813
|
+
auto.global.push(...found.global)
|
|
814
|
+
auto.contextual.push(...found.contextual)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const packageEntries = nested.filter(([, , pkg]) => pkg)
|
|
818
|
+
const safeAlias = (r) => !['lib/', 'src/', 'contracts/'].includes(r.name)
|
|
819
|
+
const global = auto.global.map((r) => configuredAutoRemapping(r, packageEntries)).filter(safeAlias)
|
|
820
|
+
const contextual = auto.contextual.map((r) => configuredAutoRemapping(r, packageEntries)).filter(safeAlias)
|
|
821
|
+
const detected = [...global, ...contextual]
|
|
822
|
+
|
|
823
|
+
const targetsByAlias = new Map()
|
|
824
|
+
for (const r of detected) {
|
|
825
|
+
if (!targetsByAlias.has(r.name)) targetsByAlias.set(r.name, new Set())
|
|
826
|
+
targetsByAlias.get(r.name).add(pathKey(r.path))
|
|
827
|
+
}
|
|
828
|
+
const ambiguous = new Set([...targetsByAlias].filter(([, t]) => t.size > 1).map(([name]) => name))
|
|
829
|
+
|
|
830
|
+
const explicitContextual = nested.filter(([, r]) => r.context !== null).flatMap(([, r]) => withOverlays(authoritativeUser, r))
|
|
831
|
+
const authoritative = [...authoritativeUser, ...explicitContextual]
|
|
832
|
+
// Forge's per-(context, alias) closest paths; only global remappings ever reach it.
|
|
833
|
+
const closest = new Map()
|
|
834
|
+
const contextualRemappings = []
|
|
835
|
+
for (const [lib, r, isPackageEntry] of nested) {
|
|
836
|
+
if (r.context !== null) continue
|
|
837
|
+
// A dependency refining an auto-detected package root to its source dir: scope the refinement
|
|
838
|
+
// to that dependency so root imports keep the broader mapping.
|
|
839
|
+
const refines = !isPackageEntry && detected.some((a) => a.name === r.name && !pathEq(r.path, a.path)
|
|
840
|
+
&& ((a.context !== null && pathEq(a.context, lib)) || (a.context === null && pathStartsWith(r.path, a.path))))
|
|
841
|
+
if (refines) contextualRemappings.push(...withOverlays(authoritative, { ...r, context: `${lib}/` }))
|
|
842
|
+
insertClosest(closest, r.name, r.path)
|
|
843
|
+
}
|
|
844
|
+
for (const r of contextual.filter((c) => ambiguous.has(c.name)).flatMap((c) => expandScopedContextual(c, host))) {
|
|
845
|
+
contextualRemappings.push(...withOverlays(authoritative, r))
|
|
846
|
+
}
|
|
847
|
+
for (const r of global) insertClosest(closest, r.name, r.path)
|
|
848
|
+
|
|
849
|
+
const explicit = new Set(all.remappings.map((r) => relKey(relativePreservingBoundary(r, root))))
|
|
850
|
+
for (const c of [...explicitContextual.toSorted(byContextDepth), ...contextualRemappings.toSorted(byContextDepth)]) {
|
|
851
|
+
if (!explicit.has(relKey(relativePreservingBoundary(c, root)))) all.push(c)
|
|
852
|
+
}
|
|
853
|
+
for (const [name, path] of sortedEntries(closest, cmpStr)) all.push({ context: null, name, path })
|
|
854
|
+
return all.intoInner()
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// The lib dirs `forge build` uses for the Foundry project at `baseDir`, `{ libs, profiled, files }`:
|
|
858
|
+
// the selected profile's `libs` (`profiled` when that profile is the file's), else the detected
|
|
859
|
+
// ones; also those, warned, when forge would reject the foundry.toml's settings (ConfigRefused; with
|
|
860
|
+
// a pinned mapping file, nothing else is read from it). `files` lists the config files read (the
|
|
861
|
+
// foundry.toml, its `extends` base). A foundry.toml that isn't TOML or holds an invalid remapping
|
|
862
|
+
// throws.
|
|
863
|
+
export function foundryLibs(baseDir, { env = process.env, host = diskHost } = {}) {
|
|
864
|
+
const root = toPosix(resolve(baseDir))
|
|
865
|
+
const profile = foundryProfile(env)
|
|
866
|
+
try {
|
|
867
|
+
const config = loadFoundryConfig(root, profile, { host })
|
|
868
|
+
return { libs: config.libs, profiled: profileApplies(config.profiles, profile), files: config.files }
|
|
869
|
+
} catch (err) {
|
|
870
|
+
if (!(err instanceof ConfigRefused)) throw err
|
|
871
|
+
console.warn(`[loader.solidity] Using the default lib dirs: ${err.message}`)
|
|
872
|
+
return { libs: detectLibs(root, host), profiled: false, files: [rustJoin(root, FOUNDRY_TOML)] }
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// The source directory of the Foundry project at `baseDir`: its default profile's `src`, else
|
|
877
|
+
// forge's (`src` unless only `contracts` exists).
|
|
878
|
+
export function foundrySourceDir(baseDir, { host = diskHost } = {}) {
|
|
879
|
+
return loadFoundryConfig(toPosix(resolve(baseDir)), 'default', { host }).src
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// The Foundry project at `baseDir`: what `forge build` would use. `remappings` are
|
|
883
|
+
// `{ context, prefix, target }` relative to the root, in forge's order; `libs` the lib dirs;
|
|
884
|
+
// `files` the config files read (project-relative, `../` when outside the project); `envUsed` the
|
|
885
|
+
// environment variables that shaped them; `ownership` its files' owners (see solidityOwnership),
|
|
886
|
+
// which also confines what a dependency's config reads. `env` supplies FOUNDRY_PROFILE and
|
|
887
|
+
// FOUNDRY_REMAPPINGS / DAPP_REMAPPINGS.
|
|
888
|
+
export function foundryProject(baseDir, { env = process.env, host = diskHost } = {}) {
|
|
889
|
+
const root = toPosix(resolve(baseDir))
|
|
890
|
+
const profile = foundryProfile(env)
|
|
891
|
+
const config = loadFoundryConfig(root, profile, { host })
|
|
892
|
+
const profiled = profileApplies(config.profiles, profile)
|
|
893
|
+
const ownership = projectOwnership(baseDir, config.libs, { soldeer: true, host })
|
|
894
|
+
const files = new Set(config.files)
|
|
895
|
+
|
|
896
|
+
const envName = env.DAPP_REMAPPINGS !== undefined ? 'DAPP_REMAPPINGS' : env.FOUNDRY_REMAPPINGS !== undefined ? 'FOUNDRY_REMAPPINGS' : null
|
|
897
|
+
const envRemappings = envName === null ? [] : parseRemappingLines(env[envName], { label: envName })
|
|
898
|
+
const txtFile = rustJoin(root, REMAPPINGS_TXT)
|
|
899
|
+
const txt = readUtf8OrNull(txtFile, REMAPPINGS_TXT, host)
|
|
900
|
+
if (txt !== null) files.add(txtFile)
|
|
901
|
+
const userRemappings = [...envRemappings, ...(txt === null ? [] : parseRemappingLines(txt, { label: REMAPPINGS_TXT })), ...config.remappings]
|
|
902
|
+
|
|
903
|
+
const provided = providerRemappings(root, { userRemappings, libs: config.libs, autoDetect: config.autoDetect, profile, files, ownership, host })
|
|
904
|
+
.map((r) => displayRelative(relativePreservingBoundary(r, root)))
|
|
905
|
+
|
|
906
|
+
// `forge build` re-reads them as config remappings, dropping aliases of its own input dirs.
|
|
907
|
+
const build = new Remappings([], [config.src, config.test, config.script].map((p) => `${p}/`))
|
|
908
|
+
for (const s of provided) {
|
|
909
|
+
const r = parseRemapping(s)
|
|
910
|
+
if (r) build.push(r)
|
|
911
|
+
}
|
|
912
|
+
// ...and hands them to solc as the config's `RelativeRemapping`s: slash-terminated.
|
|
913
|
+
const remappings = build.intoInner()
|
|
914
|
+
.map((r) => parseRemapping(displayRelative(relativePreservingBoundary(r, root))))
|
|
915
|
+
.filter(Boolean)
|
|
916
|
+
.map(toSolcRemapping)
|
|
917
|
+
|
|
918
|
+
// One read from outside the root (an `extends = "../base.toml"`) stays `../`, for --manifests to
|
|
919
|
+
// refuse (it can't be carried).
|
|
920
|
+
const relFiles = [...files].map((f) => projectRelative(root, f, host))
|
|
921
|
+
const envUsed = [...(profiled ? [`FOUNDRY_PROFILE=${env.FOUNDRY_PROFILE}`] : []), ...(envName === null ? [] : [envName])]
|
|
922
|
+
return { remappings, libs: config.libs, files: relFiles, envUsed, ownership }
|
|
923
|
+
}
|