@exodus/stasis 1.0.0-alpha.1 → 1.0.0-alpha.3

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.3",
4
+ "description": "File-level source bundles and lockfiles",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  "./esbuild": "./src/esbuild.js",
@@ -9,16 +9,21 @@
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",
20
+ "src/audit.js",
18
21
  "src/bundle.js",
22
+ "src/cmd/bundle.js",
19
23
  "src/config.js",
20
24
  "src/esbuild.js",
21
25
  "src/loader.js",
26
+ "src/loaders/solidity.js",
22
27
  "src/lockfile.js",
23
28
  "src/prune.js",
24
29
  "src/state.js",
@@ -39,14 +44,14 @@
39
44
  },
40
45
  "repository": {
41
46
  "type": "git",
42
- "url": "git+https://github.com/ExodusMovement/stasis.git"
47
+ "url": "git+https://github.com/ExodusOSS/stasis.git"
43
48
  },
44
49
  "author": "Exodus Movement, Inc.",
45
50
  "license": "MIT",
46
51
  "bugs": {
47
- "url": "https://github.com/ExodusMovement/stasis/issues"
52
+ "url": "https://github.com/ExodusOSS/stasis/issues"
48
53
  },
49
- "homepage": "https://github.com/ExodusMovement/stasis#readme",
54
+ "homepage": "https://github.com/ExodusOSS/stasis#readme",
50
55
  "devDependencies": {
51
56
  "@exodus/bytes": "^1.15.0",
52
57
  "esbuild": "^0.27.3",
package/src/audit.js ADDED
@@ -0,0 +1,149 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { brotliDecompressSync } from 'node:zlib'
3
+
4
+ import { advisories } from './apis/npm/index.js'
5
+ import semver from './apis/npm/semver.cjs'
6
+ import { Bundle } from './bundle.js'
7
+ import { Lockfile } from './lockfile.js'
8
+
9
+ function parseFile(file) {
10
+ let buf
11
+ try {
12
+ buf = readFileSync(file)
13
+ } catch (cause) {
14
+ if (cause.code === 'ENOENT') throw new Error(`File not found: ${file}`, { cause })
15
+ throw new Error(`Failed to read ${file}: ${cause.message}`, { cause })
16
+ }
17
+ // Lockfiles are JSON text starting with `{`; code/resource bundles are
18
+ // brotli-compressed JSON. Sniff to route to the right parser so its
19
+ // diagnostic surfaces on failure.
20
+ if (buf[0] === 0x7b /* '{' */) {
21
+ try {
22
+ return Lockfile.parse(buf.toString('utf8'))
23
+ } catch (cause) {
24
+ throw new Error(`Failed to parse stasis lockfile: ${file}`, { cause })
25
+ }
26
+ }
27
+ let text
28
+ try {
29
+ text = brotliDecompressSync(buf).toString('utf8')
30
+ } catch (cause) {
31
+ throw new Error(`Failed to read ${file} as a stasis bundle (not brotli)`, { cause })
32
+ }
33
+ // Code bundles carry `formats`/`imports`; resource bundles don't. Route on shape.
34
+ try {
35
+ const isCode = JSON.parse(text).formats !== undefined
36
+ return isCode ? Bundle.parseCode(text) : Bundle.parseResources(text)
37
+ } catch (cause) {
38
+ throw new Error(`Failed to parse stasis bundle: ${file}`, { cause })
39
+ }
40
+ }
41
+
42
+ // Only audit installed dependencies; workspace/first-party packages live under
43
+ // non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
44
+ // shouldn't be sent to the public registry — they leak names and produce noise.
45
+ export function collectPackagesFromFile(file) {
46
+ const out = []
47
+ for (const [dir, { name, version }] of parseFile(file).modules) {
48
+ if (!dir.includes('node_modules')) continue
49
+ if (name && version) out.push({ name, version })
50
+ }
51
+ return out
52
+ }
53
+
54
+ export function collectPackages(files) {
55
+ const seen = new Set()
56
+ const out = []
57
+ for (const file of files) {
58
+ for (const { name, version } of collectPackagesFromFile(file)) {
59
+ const key = `${name}@${version}`
60
+ if (seen.has(key)) continue
61
+ seen.add(key)
62
+ out.push({ name, version })
63
+ }
64
+ }
65
+ return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
66
+ }
67
+
68
+ const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
69
+
70
+ export function flattenAdvisories(result, packages = []) {
71
+ const installed = new Map()
72
+ for (const { name, version } of packages) {
73
+ if (!installed.has(name)) installed.set(name, [])
74
+ installed.get(name).push(version)
75
+ }
76
+ const rows = []
77
+ for (const [pkg, list] of Object.entries(result)) {
78
+ if (!Array.isArray(list)) continue
79
+ const installedVersions = installed.get(pkg) ?? []
80
+ for (const adv of list) {
81
+ const range = adv.vulnerable_versions ?? ''
82
+ const affected = range
83
+ ? installedVersions.filter((v) => semver.satisfies(v, range))
84
+ : installedVersions
85
+ // npm occasionally returns advisories whose vulnerable range matches none
86
+ // of the versions we submitted; drop them so the table (and the CLI exit
87
+ // code) reflect only real hits.
88
+ if (installedVersions.length > 0 && affected.length === 0) continue
89
+ rows.push({
90
+ package: pkg,
91
+ installed: affected.join(', '),
92
+ vulnerable: range,
93
+ severity: adv.severity ?? '',
94
+ title: adv.title ?? '',
95
+ url: adv.url ?? '',
96
+ })
97
+ }
98
+ }
99
+ rows.sort((a, b) => {
100
+ const sa = SEVERITY_ORDER[a.severity] ?? 99
101
+ const sb = SEVERITY_ORDER[b.severity] ?? 99
102
+ if (sa !== sb) return sa - sb
103
+ if (a.package !== b.package) return a.package < b.package ? -1 : 1
104
+ return a.title < b.title ? -1 : 1
105
+ })
106
+ return rows
107
+ }
108
+
109
+ // Line breaks in npm advisory titles would otherwise break the box layout.
110
+ // Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
111
+ const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
112
+
113
+ export function formatTable(rows, columns) {
114
+ if (rows.length === 0) return ''
115
+ const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
116
+ const pad = (s, w) => s.padEnd(w)
117
+ const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
118
+ const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
119
+ return [
120
+ line('┌', '┬', '┐', '─'),
121
+ row(columns),
122
+ line('├', '┼', '┤', '─'),
123
+ ...rows.map((r) => row(columns.map((c) => r[c]))),
124
+ line('└', '┴', '┘', '─'),
125
+ ].join('\n')
126
+ }
127
+
128
+ export async function audit(files) {
129
+ const packages = collectPackages(files)
130
+ if (packages.length === 0) {
131
+ return { packages, advisories: {}, rows: [] }
132
+ }
133
+ const result = await advisories(packages)
134
+ const rows = flattenAdvisories(result, packages)
135
+ return { packages, advisories: result, rows }
136
+ }
137
+
138
+ export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
139
+ err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
140
+ if (packages.length === 0) {
141
+ err.write('No node_modules entries found in the input files\n')
142
+ return
143
+ }
144
+ if (rows.length === 0) {
145
+ err.write('No advisories found\n')
146
+ return
147
+ }
148
+ out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
149
+ }
@@ -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, baseDir })
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,210 @@
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 { createRequire } from 'node:module'
10
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
11
+
12
+ const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
13
+
14
+ export function extractSolImports(content) {
15
+ return [...content.matchAll(SOL_IMPORT_RE)].map((m) => m[1])
16
+ }
17
+
18
+ export function parseRemappingsFromToml(tomlContent) {
19
+ const match = tomlContent.match(/remappings\s*=\s*\[([\s\S]*?)\]/u)
20
+ if (!match) return []
21
+ return parseRemappings([...match[1].matchAll(/["']([^"']+)["']/gu)].map((m) => m[1]).join('\n'))
22
+ }
23
+
24
+ export function parseRemappings(content) {
25
+ return content.trim().split('\n').map((entry) => {
26
+ const eqIdx = entry.indexOf('=')
27
+ if (eqIdx === -1) return null
28
+ return { prefix: entry.slice(0, eqIdx), target: entry.slice(eqIdx + 1) }
29
+ }).filter(Boolean)
30
+ }
31
+
32
+ // Resolve a Solidity import specifier to a baseDir-relative POSIX path,
33
+ // trying three strategies in order:
34
+ // 1. user remappings (longest prefix wins)
35
+ // 2. relative path (`./...` or `../...`); traversal above the project
36
+ // root returns null instead of clamping
37
+ // 3. Node's CJS resolver via createRequire, anchored at the importing
38
+ // source — only when `baseDir` is provided and the specifier looks
39
+ // like a scoped npm package (`@scope/pkg/sub/path`)
40
+ // Returns null when no strategy resolves the import.
41
+ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
42
+ let best = null
43
+ for (const { prefix, target } of remappings) {
44
+ if (specifier.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) {
45
+ best = { prefix, target }
46
+ }
47
+ }
48
+ if (best) {
49
+ const target = best.target.replace(/\/$/u, '')
50
+ const remaining = specifier.slice(best.prefix.length).replace(/^\//u, '')
51
+ return (remaining ? `${target}/${remaining}` : target).replace(/^\.\//u, '')
52
+ }
53
+
54
+ if (specifier.startsWith('.')) {
55
+ const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
56
+ const parts = [...(fromDir ? fromDir.split('/') : []), ...specifier.split('/')]
57
+ const resolved = []
58
+ for (const part of parts) {
59
+ if (part === '.' || part === '') continue
60
+ if (part === '..') {
61
+ if (resolved.length === 0) return null
62
+ resolved.pop()
63
+ } else {
64
+ resolved.push(part)
65
+ }
66
+ }
67
+ return resolved.join('/')
68
+ }
69
+
70
+ // Node-style `@scope/pkg/sub/path`. createRequire is anchored at the
71
+ // importing source so nested node_modules resolve like Node would
72
+ // (a file under `node_modules/foo/` finds its deps in
73
+ // `foo/node_modules/` before walking up). `..` segments in the
74
+ // subpath are rejected so a crafted specifier can't escape the
75
+ // resolved package via require.resolve's own path normalization.
76
+ if (baseDir && specifier.startsWith('@')) {
77
+ const parts = specifier.split('/')
78
+ if (parts.length < 3) return null
79
+ if (parts.slice(2).includes('..')) return null
80
+ try {
81
+ const abs = createRequire(resolve(baseDir, fromFile)).resolve(specifier)
82
+ const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
83
+ if (!rel.startsWith('..') && !isAbsolute(rel)) return rel
84
+ } catch { /* package missing, exports map blocks the subpath, … */ }
85
+ }
86
+
87
+ return null
88
+ }
89
+
90
+ // Build the { sources, resolutions, missing } triple from a Map of
91
+ // already-loaded Solidity sources plus optional remappings. Imports that
92
+ // resolve via remappings/relative/Node win; as a final fallback an
93
+ // import specifier that matches a stored key verbatim (etherscan
94
+ // bundles, hardhat flat layouts) is accepted. `missing` lists every
95
+ // (spec, from) pair that could not be resolved or that resolved to a
96
+ // file not loaded into `sources`.
97
+ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
98
+ const resolutions = new Map()
99
+ const missing = []
100
+ for (const [path, content] of sources) {
101
+ const specMap = new Map()
102
+ for (const spec of extractSolImports(content)) {
103
+ let resolved = resolveSolImport(spec, path, { remappings, baseDir })
104
+ if (resolved && !sources.has(resolved)) resolved = null
105
+ if (!resolved && sources.has(spec)) resolved = spec
106
+ if (resolved) {
107
+ specMap.set(spec, resolved)
108
+ } else {
109
+ console.warn(`[loader.solidity] Missing import: ${spec} from ${path}`)
110
+ missing.push({ spec, from: path })
111
+ }
112
+ }
113
+ resolutions.set(path, specMap)
114
+ }
115
+ return { sources, resolutions, missing }
116
+ }
117
+
118
+ // Walk the filesystem starting from `entries`, following resolved
119
+ // imports and reading each file exactly once. Files in the same wave
120
+ // are read in parallel; the next wave depends on the imports
121
+ // discovered in the previous one. Caller-listed entries are also
122
+ // accepted as verbatim non-relative import targets (Foundry-style
123
+ // `import "src/A.sol"`).
124
+ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
125
+ const sources = new Map()
126
+ const knownEntries = new Set(entries)
127
+
128
+ const processWave = async (wave) => {
129
+ const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
130
+ if (toLoad.length === 0) return
131
+ const reads = await Promise.all(
132
+ toLoad.map(async (relPath) => {
133
+ try {
134
+ return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
135
+ } catch (err) {
136
+ if (err.code === 'ENOENT') {
137
+ console.warn(`[loader.solidity] Missing import: ${relPath}`)
138
+ return null
139
+ }
140
+ throw err
141
+ }
142
+ })
143
+ )
144
+ const next = []
145
+ for (const entry of reads) {
146
+ if (!entry) continue
147
+ const [relPath, content] = entry
148
+ sources.set(relPath, content)
149
+ for (const spec of extractSolImports(content)) {
150
+ let resolved = resolveSolImport(spec, relPath, { remappings, baseDir })
151
+ if (!resolved && knownEntries.has(spec)) resolved = spec
152
+ if (resolved) {
153
+ if (!sources.has(resolved)) next.push(resolved)
154
+ } else {
155
+ console.warn(`[loader.solidity] Missing import: ${spec} from ${relPath}`)
156
+ }
157
+ }
158
+ }
159
+ await processWave(next)
160
+ }
161
+
162
+ await processWave(entries)
163
+ return sources
164
+ }
165
+
166
+ // Read a foundry.toml or remappings.txt mapping file and return its parsed
167
+ // remappings. The mapping file itself is NOT included in the loaded sources.
168
+ export async function readRemappingsFile(mappingFile) {
169
+ const content = await readFile(mappingFile, 'utf8')
170
+ if (mappingFile.endsWith('.toml')) return parseRemappingsFromToml(content)
171
+ return parseRemappings(content)
172
+ }
173
+
174
+ // Reject absolute paths and `..`-escaping paths in a `.sol.txt` listing so
175
+ // a malicious or sloppy listing can't trick the loader into reading
176
+ // arbitrary files outside the listing's own directory.
177
+ function assertWithinBase(baseDir, candidate, label) {
178
+ if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
179
+ const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
180
+ if (rel.startsWith('..') || isAbsolute(rel)) {
181
+ throw new Error(`${label} escapes baseDir: ${candidate}`)
182
+ }
183
+ }
184
+
185
+ // High-level: takes an optional .sol.txt listing file. The first line may
186
+ // be a `*.toml` or `remappings.txt` mapping file (resolved relative to the
187
+ // listing); the remaining lines are `*.sol` files. Mirrors the DeepView
188
+ // loader's interface.
189
+ export async function loadSolidity(solTxtFile) {
190
+ const baseDir = dirname(resolve(solTxtFile))
191
+ const listing = await readFile(solTxtFile, 'utf8')
192
+ const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
193
+ if (lines.length === 0) throw new Error(`Empty Solidity listing: ${solTxtFile}`)
194
+
195
+ let remappings = []
196
+ if (lines[0].endsWith('.toml') || lines[0].endsWith('remappings.txt')) {
197
+ const mapping = lines.shift()
198
+ assertWithinBase(baseDir, mapping, 'Mapping path')
199
+ remappings = await readRemappingsFile(join(baseDir, mapping))
200
+ }
201
+
202
+ if (!lines.every((line) => line.endsWith('.sol'))) {
203
+ throw new Error(`Solidity listing must only contain .sol files: ${solTxtFile}`)
204
+ }
205
+
206
+ const entries = lines.map((l) => l.replace(/^\.\//u, ''))
207
+ for (const e of entries) assertWithinBase(baseDir, e, 'Entry path')
208
+ const sources = await collectSolidityFilesFromDisk(baseDir, entries, remappings)
209
+ return buildSolidityTree(sources, { remappings, baseDir })
210
+ }