@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 CHANGED
@@ -1,4 +1,4 @@
1
- # `@exodus/stasis`
1
+ # <img src="./logo.svg" alt="" width="39" height="39" valign="bottom" /> `@exodus/stasis`
2
2
 
3
3
  Analyzeable source code bundles with resolutions + per-file lockfiles
4
4
 
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 create path/to/lockfile
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
- usage('bundle command is not implemented yet')
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.1",
4
- "description": "A research project",
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/ExodusMovement/stasis.git"
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/ExodusMovement/stasis/issues"
51
+ "url": "https://github.com/ExodusOSS/stasis/issues"
48
52
  },
49
- "homepage": "https://github.com/ExodusMovement/stasis#readme",
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
+ }