@exodus/stasis 1.0.0-alpha.1 → 1.0.0-alpha.2
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 +1 -1
- package/bin/stasis.js +25 -3
- package/logo.svg +11 -0
- package/package.json +10 -6
- package/src/cmd/bundle.js +188 -0
- package/src/loaders/solidity.js +214 -0
package/README.md
CHANGED
package/bin/stasis.js
CHANGED
|
@@ -20,8 +20,7 @@ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.
|
|
|
20
20
|
function usage(prefix = '') {
|
|
21
21
|
console.error(`${prefix}\nUsage:
|
|
22
22
|
stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ...
|
|
23
|
-
stasis bundle
|
|
24
|
-
stasis bundle verify path/to/lockfile
|
|
23
|
+
stasis bundle [--mapping=path/to/remappings(.txt|.toml)] [--output=path/to/out.stasis.code.br] path/to/file.sol ...
|
|
25
24
|
stasis prune [path/to/project]
|
|
26
25
|
stasis audit path/to/file ...
|
|
27
26
|
`.trim())
|
|
@@ -77,7 +76,30 @@ if (command === '-v' || command === '--version') {
|
|
|
77
76
|
const [code] = await once(child, 'close')
|
|
78
77
|
process.exitCode = code
|
|
79
78
|
} else if (command === 'bundle') {
|
|
80
|
-
|
|
79
|
+
const flags = []
|
|
80
|
+
const valueFlags = new Set(['--mapping', '--output', '-o'])
|
|
81
|
+
while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
|
|
82
|
+
flags.push(argv.shift())
|
|
83
|
+
}
|
|
84
|
+
const options = {
|
|
85
|
+
mapping: { type: 'string' },
|
|
86
|
+
output: { type: 'string', short: 'o' },
|
|
87
|
+
}
|
|
88
|
+
let values
|
|
89
|
+
try {
|
|
90
|
+
({ values } = parseArgs({ args: flags, options }))
|
|
91
|
+
} catch (cause) {
|
|
92
|
+
usage(`Error: ${cause.message}`)
|
|
93
|
+
}
|
|
94
|
+
if (argv.length === 0) usage('Nothing to bundle: no .sol file given')
|
|
95
|
+
if (!argv.every((f) => f.endsWith('.sol'))) usage('Error: bundle only accepts .sol files')
|
|
96
|
+
const { bundleCommand } = await import('../src/cmd/bundle.js')
|
|
97
|
+
await bundleCommand({
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
entries: argv,
|
|
100
|
+
mappingFile: values.mapping,
|
|
101
|
+
output: values.output,
|
|
102
|
+
})
|
|
81
103
|
} else if (command === 'prune') {
|
|
82
104
|
if (argv.length > 1) usage('Error: prune takes at most one path argument')
|
|
83
105
|
const root = argv[0] ? resolve(argv[0]) : process.cwd()
|
package/logo.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Stasis" viewBox="0 0 128 128">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="a" x1="0" x2="1" y1="0" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#1f9cff"/>
|
|
5
|
+
<stop offset="60%" stop-color="#8c5bfe"/>
|
|
6
|
+
<stop offset="100%" stop-color="#b85bfe"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="128" height="128" fill="rgba(150,150,150,0.09)" rx="28" ry="28"/>
|
|
10
|
+
<circle cx="64" cy="64" r="28.16" fill="url(#a)"/>
|
|
11
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/stasis",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
|
+
"description": "File-level source bundles and lockfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./esbuild": "./src/esbuild.js",
|
|
@@ -9,16 +9,20 @@
|
|
|
9
9
|
"./loader": "./src/loader.js",
|
|
10
10
|
"./bundle": "./src/bundle.js",
|
|
11
11
|
"./lockfile": "./src/lockfile.js",
|
|
12
|
-
"./prune": "./src/prune.js"
|
|
12
|
+
"./prune": "./src/prune.js",
|
|
13
|
+
"./logo.svg": "./logo.svg"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"bin/stasis.js",
|
|
17
|
+
"logo.svg",
|
|
16
18
|
"src/apis/npm/index.js",
|
|
17
19
|
"src/apis/npm/semver.cjs",
|
|
18
20
|
"src/bundle.js",
|
|
21
|
+
"src/cmd/bundle.js",
|
|
19
22
|
"src/config.js",
|
|
20
23
|
"src/esbuild.js",
|
|
21
24
|
"src/loader.js",
|
|
25
|
+
"src/loaders/solidity.js",
|
|
22
26
|
"src/lockfile.js",
|
|
23
27
|
"src/prune.js",
|
|
24
28
|
"src/state.js",
|
|
@@ -39,14 +43,14 @@
|
|
|
39
43
|
},
|
|
40
44
|
"repository": {
|
|
41
45
|
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/
|
|
46
|
+
"url": "git+https://github.com/ExodusOSS/stasis.git"
|
|
43
47
|
},
|
|
44
48
|
"author": "Exodus Movement, Inc.",
|
|
45
49
|
"license": "MIT",
|
|
46
50
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/
|
|
51
|
+
"url": "https://github.com/ExodusOSS/stasis/issues"
|
|
48
52
|
},
|
|
49
|
-
"homepage": "https://github.com/
|
|
53
|
+
"homepage": "https://github.com/ExodusOSS/stasis#readme",
|
|
50
54
|
"devDependencies": {
|
|
51
55
|
"@exodus/bytes": "^1.15.0",
|
|
52
56
|
"esbuild": "^0.27.3",
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'
|
|
3
|
+
import { brotliCompressSync } from 'node:zlib'
|
|
4
|
+
|
|
5
|
+
import { Bundle } from '../bundle.js'
|
|
6
|
+
import { splitNodeModulesPath } from '../util.js'
|
|
7
|
+
import {
|
|
8
|
+
buildSolidityTree,
|
|
9
|
+
collectSolidityFilesFromDisk,
|
|
10
|
+
readRemappingsFile,
|
|
11
|
+
} from '../loaders/solidity.js'
|
|
12
|
+
|
|
13
|
+
// Fallback package identity for the workspace bucket when no package.json
|
|
14
|
+
// with a name+version can be found by walking up from any of the bundled
|
|
15
|
+
// files. Bundle.parseCode requires every workspace/module bucket to have
|
|
16
|
+
// both fields, so we have to attest something.
|
|
17
|
+
const SOLIDITY_WORKSPACE_NAME = 'solidity-bundle'
|
|
18
|
+
const SOLIDITY_WORKSPACE_VERSION = '0.0.0'
|
|
19
|
+
const SOLIDITY_FORMAT = 'solidity'
|
|
20
|
+
|
|
21
|
+
// Walk up from the file's directory looking for the nearest package.json
|
|
22
|
+
// with both `name` and `version`. Returns { pkgDir, name, version }
|
|
23
|
+
// (pkgDir relative to `baseDir`, "." for the project root) or null when no
|
|
24
|
+
// such package.json exists at or above the file.
|
|
25
|
+
function findPackageMetadata(baseDir, fileRelPath) {
|
|
26
|
+
let dir = dirname(fileRelPath)
|
|
27
|
+
while (true) {
|
|
28
|
+
const pkgPath = join(baseDir, dir, 'package.json')
|
|
29
|
+
if (existsSync(pkgPath)) {
|
|
30
|
+
try {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
32
|
+
if (pkg.name && pkg.version) {
|
|
33
|
+
return { pkgDir: dir, name: pkg.name, version: pkg.version }
|
|
34
|
+
}
|
|
35
|
+
} catch { /* malformed — keep walking */ }
|
|
36
|
+
}
|
|
37
|
+
if (dir === '.' || dir === '/' || dir === '') return null
|
|
38
|
+
const parent = dirname(dir)
|
|
39
|
+
if (parent === dir) return null
|
|
40
|
+
dir = parent
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeEntries(entries, cwd) {
|
|
45
|
+
const baseDir = resolve(cwd)
|
|
46
|
+
return entries.map((e) => {
|
|
47
|
+
const abs = resolve(cwd, e)
|
|
48
|
+
const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
|
|
49
|
+
// On Windows, `path.relative()` returns an absolute path when `abs` is on
|
|
50
|
+
// a different drive than `baseDir` — that wouldn't start with `..` but
|
|
51
|
+
// still escapes, so reject both forms.
|
|
52
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`Entry escapes baseDir: ${e}`)
|
|
53
|
+
return rel.replace(/^\.\//u, '')
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Deepest directory that is a parent of every file in `paths`, expressed
|
|
58
|
+
// relative to `cwd`. `paths` may be POSIX-relative-to-cwd; entries that
|
|
59
|
+
// escape cwd (e.g. `../deps/X.sol` via remapping) push the result above
|
|
60
|
+
// cwd, in which case the returned path starts with `..`. Returns "." when
|
|
61
|
+
// the outermost directory IS cwd itself.
|
|
62
|
+
export function outermostDir(paths, cwd) {
|
|
63
|
+
if (paths.length === 0) return '.'
|
|
64
|
+
const cwdAbs = posix.resolve(cwd.replaceAll(/\\/gu, '/'))
|
|
65
|
+
const absDirs = paths.map((p) => posix.dirname(posix.resolve(cwdAbs, p)))
|
|
66
|
+
const partsList = absDirs.map((d) => d.split('/'))
|
|
67
|
+
const common = []
|
|
68
|
+
for (let i = 0; i < partsList[0].length; i++) {
|
|
69
|
+
const c = partsList[0][i]
|
|
70
|
+
if (!partsList.every((parts) => parts[i] === c)) break
|
|
71
|
+
common.push(c)
|
|
72
|
+
}
|
|
73
|
+
const absCommon = common.join('/') || '/'
|
|
74
|
+
const rel = posix.relative(cwdAbs, absCommon)
|
|
75
|
+
return rel === '' ? '.' : rel
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Build a stasis Bundle (in-memory) from a list of entry .sol files and
|
|
79
|
+
// an optional mapping file. The mapping file (foundry.toml or
|
|
80
|
+
// remappings.txt) is parsed for remappings but NOT included in the
|
|
81
|
+
// bundle's sources. Returns the constructed `Bundle`.
|
|
82
|
+
export async function buildSolidityBundle({ cwd = process.cwd(), entries, mappingFile } = {}) {
|
|
83
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
84
|
+
throw new Error('buildSolidityBundle: at least one entry .sol file is required')
|
|
85
|
+
}
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (!e.endsWith('.sol')) throw new Error(`buildSolidityBundle: not a .sol file: ${e}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const baseDir = resolve(cwd)
|
|
91
|
+
const remappings = mappingFile ? await readRemappingsFile(resolve(cwd, mappingFile)) : []
|
|
92
|
+
const normalized = normalizeEntries(entries, cwd)
|
|
93
|
+
|
|
94
|
+
const sources = await collectSolidityFilesFromDisk(baseDir, normalized, remappings)
|
|
95
|
+
const { resolutions, missing } = buildSolidityTree(sources, { remappings })
|
|
96
|
+
|
|
97
|
+
// Bundles must be self-contained. Refuse to write one when an entry
|
|
98
|
+
// can't be loaded from disk, or when any in-bundle file has an import
|
|
99
|
+
// we couldn't resolve — otherwise downstream consumers would silently
|
|
100
|
+
// operate on a partial set of sources.
|
|
101
|
+
const issues = []
|
|
102
|
+
for (const entry of normalized) {
|
|
103
|
+
if (!sources.has(entry)) issues.push(`Missing entry: ${entry}`)
|
|
104
|
+
}
|
|
105
|
+
for (const { spec, from } of missing) {
|
|
106
|
+
issues.push(`Unresolved import: ${spec} from ${from}`)
|
|
107
|
+
}
|
|
108
|
+
if (issues.length > 0) {
|
|
109
|
+
throw new Error(`Solidity bundle has unresolved imports:\n${issues.map((s) => ` ${s}`).join('\n')}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Partition every loaded .sol file into a per-package bucket. The
|
|
113
|
+
// bucket is the directory holding the nearest package.json with a
|
|
114
|
+
// name+version. node_modules files land in `node_modules/<pkg>` buckets
|
|
115
|
+
// (or deeper, when a nested package.json claims the file); workspace
|
|
116
|
+
// files land in their nearest workspace package.json's dir, or in the
|
|
117
|
+
// fallback "." bucket with placeholder metadata when no package.json
|
|
118
|
+
// could be found.
|
|
119
|
+
const modules = new Map()
|
|
120
|
+
const ensureBucket = (dir, name, version) => {
|
|
121
|
+
if (!modules.has(dir)) modules.set(dir, { name, version, files: Object.create(null) })
|
|
122
|
+
return modules.get(dir)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const [path, content] of sources) {
|
|
126
|
+
const meta = findPackageMetadata(baseDir, path)
|
|
127
|
+
const inNodeModules = splitNodeModulesPath(path) !== null
|
|
128
|
+
if (meta) {
|
|
129
|
+
// A file inside node_modules whose nearest package.json is the
|
|
130
|
+
// workspace root would silently land in the workspace bucket and
|
|
131
|
+
// hide the misconfigured dependency — refuse instead.
|
|
132
|
+
if (inNodeModules && !meta.pkgDir.includes('node_modules')) {
|
|
133
|
+
throw new Error(`No package.json with name+version found for ${path}`)
|
|
134
|
+
}
|
|
135
|
+
const rel = meta.pkgDir === '.' ? path : path.slice(meta.pkgDir.length + 1)
|
|
136
|
+
ensureBucket(meta.pkgDir, meta.name, meta.version).files[rel] = content
|
|
137
|
+
} else {
|
|
138
|
+
// Files under node_modules without any discoverable package.json
|
|
139
|
+
// would otherwise leak into the workspace bucket; that's a project
|
|
140
|
+
// misconfiguration, surface it loudly.
|
|
141
|
+
if (inNodeModules) throw new Error(`No package.json with name+version found for ${path}`)
|
|
142
|
+
ensureBucket('.', SOLIDITY_WORKSPACE_NAME, SOLIDITY_WORKSPACE_VERSION).files[path] = content
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const formats = new Map()
|
|
147
|
+
for (const path of sources.keys()) formats.set(path, SOLIDITY_FORMAT)
|
|
148
|
+
|
|
149
|
+
// Solidity imports don't depend on Node conditions; key them under a
|
|
150
|
+
// dedicated "solidity" bucket rather than the wildcard "*" used for
|
|
151
|
+
// JS code bundles.
|
|
152
|
+
const importsForKey = new Map()
|
|
153
|
+
for (const [parent, specMap] of resolutions) importsForKey.set(parent, specMap)
|
|
154
|
+
const imports = new Map([['solidity', importsForKey]])
|
|
155
|
+
|
|
156
|
+
return new Bundle({
|
|
157
|
+
config: { scope: 'full' },
|
|
158
|
+
entries: new Set(normalized),
|
|
159
|
+
modules,
|
|
160
|
+
formats,
|
|
161
|
+
imports,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Run the bundle CLI command end-to-end. Always produces a brotli-compressed
|
|
166
|
+
// stasis bundle (matching the on-disk format of `stasis.code.br`). When
|
|
167
|
+
// `output` is provided, writes to that path; otherwise writes to stdout.
|
|
168
|
+
// Prints a one-line `[stasis] Bundled <n> files from <dir> to <dest>` summary
|
|
169
|
+
// to stderr so it doesn't interleave with the binary output on stdout.
|
|
170
|
+
export async function bundleCommand({ cwd = process.cwd(), entries, mappingFile, output } = {}) {
|
|
171
|
+
const bundle = await buildSolidityBundle({ cwd, entries, mappingFile })
|
|
172
|
+
const data = brotliCompressSync(bundle.serializeCode())
|
|
173
|
+
if (output) {
|
|
174
|
+
const outAbs = resolve(cwd, output)
|
|
175
|
+
mkdirSync(dirname(outAbs), { recursive: true })
|
|
176
|
+
writeFileSync(outAbs, data)
|
|
177
|
+
} else {
|
|
178
|
+
process.stdout.write(data)
|
|
179
|
+
}
|
|
180
|
+
// Bundle.sources flattens every per-package bucket into project-relative
|
|
181
|
+
// paths, so the summary reflects every file we wrote — workspace and
|
|
182
|
+
// node_modules alike — not just the workspace bucket.
|
|
183
|
+
const files = [...bundle.sources.keys()]
|
|
184
|
+
const fromDir = outermostDir(files, resolve(cwd))
|
|
185
|
+
const dest = output ?? '<stdout>'
|
|
186
|
+
console.warn(`[stasis] Bundled ${files.length} files from ${fromDir} to ${dest}`)
|
|
187
|
+
return bundle
|
|
188
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Based on DeepView's Solidity loader.
|
|
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`.
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
|
|
12
|
+
|
|
13
|
+
export function extractSolImports(content) {
|
|
14
|
+
return [...content.matchAll(SOL_IMPORT_RE)].map((m) => m[1])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseRemappingsFromToml(tomlContent) {
|
|
18
|
+
const match = tomlContent.match(/remappings\s*=\s*\[([\s\S]*?)\]/u)
|
|
19
|
+
if (!match) return []
|
|
20
|
+
return parseRemappings([...match[1].matchAll(/["']([^"']+)["']/gu)].map((m) => m[1]).join('\n'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseRemappings(content) {
|
|
24
|
+
return content.trim().split('\n').map((entry) => {
|
|
25
|
+
const eqIdx = entry.indexOf('=')
|
|
26
|
+
if (eqIdx === -1) return null
|
|
27
|
+
return { prefix: entry.slice(0, eqIdx), target: entry.slice(eqIdx + 1) }
|
|
28
|
+
}).filter(Boolean)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns a candidate resolved path, or null if the specifier doesn't match
|
|
32
|
+
// any remapping and isn't a relative import. The caller is responsible for
|
|
33
|
+
// checking whether the resolved path actually exists in `sources` (the
|
|
34
|
+
// etherscan bundle's cnames are exact-match keys — `@openzeppelin/…` lives
|
|
35
|
+
// alongside `contracts/Foo.sol` — so a non-relative, non-remapped specifier
|
|
36
|
+
// may still be a hit when matched verbatim against the bundle). Logging
|
|
37
|
+
// "Missing import" also lives at the callsite because only the callsite
|
|
38
|
+
// knows whether that verbatim fallback matched.
|
|
39
|
+
export function resolveSolImport(specifier, fromFile, remappings) {
|
|
40
|
+
// Try remappings (longest prefix match)
|
|
41
|
+
let best = null
|
|
42
|
+
for (const { prefix, target } of remappings) {
|
|
43
|
+
if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
|
|
44
|
+
best = { prefix, target }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (best) {
|
|
48
|
+
const target = best.target.replace(/\/$/u, '')
|
|
49
|
+
const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
|
|
50
|
+
const result = remaining ? `${target}/${remaining}` : target
|
|
51
|
+
return result.replace(/^\.\//u, '')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Relative import. `..` segments that would traverse above the project
|
|
55
|
+
// root return null instead of silently clamping to root — clamping would
|
|
56
|
+
// change the import target into something the source never asked for.
|
|
57
|
+
if (specifier.startsWith('.')) {
|
|
58
|
+
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
59
|
+
const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
|
|
60
|
+
const resolved = []
|
|
61
|
+
for (const part of parts) {
|
|
62
|
+
if (part === '.' || part === '') continue
|
|
63
|
+
if (part === '..') {
|
|
64
|
+
if (resolved.length === 0) return null
|
|
65
|
+
resolved.pop()
|
|
66
|
+
} else {
|
|
67
|
+
resolved.push(part)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return resolved.join('/')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build the { sources, resolutions, missing } triple from a Map of
|
|
77
|
+
// already-loaded Solidity sources (any key format — relative paths for
|
|
78
|
+
// disk-loaded projects, `${address}/${cname}` for etherscan, etc.) plus
|
|
79
|
+
// optional remappings. Imports that resolve via remappings/relative paths
|
|
80
|
+
// win; as a fallback we also accept an import specifier that matches a
|
|
81
|
+
// stored key verbatim (etherscan bundles imports like
|
|
82
|
+
// `@openzeppelin/contracts/.../IERC20.sol` which is itself a stored
|
|
83
|
+
// cname). `missing` lists every (spec, from) pair that could not be
|
|
84
|
+
// resolved or that resolved to a file that wasn't loaded into `sources`.
|
|
85
|
+
export function buildSolidityTree(sources, { remappings = [] } = {}) {
|
|
86
|
+
const resolutions = new Map()
|
|
87
|
+
const missing = []
|
|
88
|
+
for (const [path, content] of sources) {
|
|
89
|
+
const specMap = new Map()
|
|
90
|
+
for (const spec of extractSolImports(content)) {
|
|
91
|
+
let resolved = resolveSolImport(spec, path, remappings)
|
|
92
|
+
if (resolved && !sources.has(resolved)) resolved = null
|
|
93
|
+
// Verbatim fallback: many Solidity ecosystems (etherscan bundles,
|
|
94
|
+
// hardhat flat layouts) ship `@openzeppelin/...` imports as literal
|
|
95
|
+
// keys in the source map. If the remap/relative resolution didn't
|
|
96
|
+
// hit but the bundle has the specifier itself as a key, use it.
|
|
97
|
+
if (!resolved && sources.has(spec)) resolved = spec
|
|
98
|
+
if (resolved) {
|
|
99
|
+
specMap.set(spec, resolved)
|
|
100
|
+
} else {
|
|
101
|
+
console.warn(`[loader.solidity] Missing import: ${spec} from ${path}`)
|
|
102
|
+
missing.push({ spec, from: path })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
resolutions.set(path, specMap)
|
|
106
|
+
}
|
|
107
|
+
return { sources, resolutions, missing }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Walk the filesystem starting from `entries`, following relative/remapped
|
|
111
|
+
// imports and reading each file exactly once. Returns a Map<relPath, content>
|
|
112
|
+
// suitable for passing into buildSolidityTree. Files in the same wave are
|
|
113
|
+
// read in parallel; subsequent waves depend on the imports discovered in
|
|
114
|
+
// the previous one.
|
|
115
|
+
//
|
|
116
|
+
// Specifiers that exactly match one of the explicit entries (the file
|
|
117
|
+
// list passed by the caller) are accepted as-is, even when they don't
|
|
118
|
+
// match any remapping. Solidity projects routinely write
|
|
119
|
+
// `import "src/A.sol"` and rely on solc's include-path / Foundry's
|
|
120
|
+
// remap-from-root behavior; here we don't have a true include path,
|
|
121
|
+
// but if the caller listed `src/A.sol` themselves we know the path is
|
|
122
|
+
// intentional and resolves to that file. Mirrors the `sources.has(spec)`
|
|
123
|
+
// fallback in `buildSolidityTree`, but applied during the walk so the
|
|
124
|
+
// file actually gets loaded into `sources`.
|
|
125
|
+
export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
|
|
126
|
+
const sources = new Map()
|
|
127
|
+
const knownEntries = new Set(entries)
|
|
128
|
+
|
|
129
|
+
const processWave = async (wave) => {
|
|
130
|
+
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
131
|
+
if (toLoad.length === 0) return
|
|
132
|
+
// A resolved path that doesn't exist on disk is treated like an
|
|
133
|
+
// unresolved import: warn and skip rather than aborting the entire
|
|
134
|
+
// walk. Other read errors (EACCES, EIO, …) still propagate.
|
|
135
|
+
const reads = await Promise.all(
|
|
136
|
+
toLoad.map(async (relPath) => {
|
|
137
|
+
try {
|
|
138
|
+
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err.code === 'ENOENT') {
|
|
141
|
+
console.warn(`[loader.solidity] Missing import: ${relPath}`)
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
const next = []
|
|
149
|
+
for (const entry of reads) {
|
|
150
|
+
if (!entry) continue
|
|
151
|
+
const [relPath, content] = entry
|
|
152
|
+
sources.set(relPath, content)
|
|
153
|
+
for (const spec of extractSolImports(content)) {
|
|
154
|
+
let resolved = resolveSolImport(spec, relPath, remappings)
|
|
155
|
+
if (!resolved && knownEntries.has(spec)) resolved = spec
|
|
156
|
+
if (resolved) {
|
|
157
|
+
if (!sources.has(resolved)) next.push(resolved)
|
|
158
|
+
} else {
|
|
159
|
+
console.warn(`[loader.solidity] Missing import: ${spec} from ${relPath}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await processWave(next)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await processWave(entries)
|
|
167
|
+
return sources
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Read a foundry.toml or remappings.txt mapping file and return its parsed
|
|
171
|
+
// remappings. The mapping file itself is NOT included in the loaded sources.
|
|
172
|
+
export async function readRemappingsFile(mappingFile) {
|
|
173
|
+
const content = await readFile(mappingFile, 'utf8')
|
|
174
|
+
if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
|
|
175
|
+
return parseRemappings(content)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reject absolute paths and `..`-escaping paths in a `.sol.txt` listing so
|
|
179
|
+
// a malicious or sloppy listing can't trick the loader into reading
|
|
180
|
+
// arbitrary files outside the listing's own directory.
|
|
181
|
+
function assertWithinBase(baseDir, candidate, label) {
|
|
182
|
+
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
183
|
+
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
184
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
185
|
+
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// High-level: takes an optional .sol.txt listing file. The first line may
|
|
190
|
+
// be a `*.toml` or `remappings.txt` mapping file (resolved relative to the
|
|
191
|
+
// listing); the remaining lines are `*.sol` files. Mirrors the DeepView
|
|
192
|
+
// loader's interface.
|
|
193
|
+
export async function loadSolidity(solTxtFile) {
|
|
194
|
+
const baseDir = dirname(resolve(solTxtFile))
|
|
195
|
+
const listing = await readFile(solTxtFile, 'utf8')
|
|
196
|
+
const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
197
|
+
if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
|
|
198
|
+
|
|
199
|
+
let remappings = []
|
|
200
|
+
if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
|
|
201
|
+
const mapping = lines.shift()
|
|
202
|
+
assertWithinBase(baseDir, mapping, 'Mapping path')
|
|
203
|
+
remappings = await readRemappingsFile(join(baseDir, mapping))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!lines.every((line) => line.endsWith('.sol'))) {
|
|
207
|
+
throw new Error(`Solidity listing must only contain .sol files: ${solTxtFile}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const entries = lines.map((l) => l.replace(/^\.\//u, ''))
|
|
211
|
+
for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
|
|
212
|
+
const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
|
|
213
|
+
return buildSolidityTree(sources, { remappings })
|
|
214
|
+
}
|