@exodus/stasis 1.0.0-beta.2 → 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.
Files changed (45) hide show
  1. package/README.md +23 -3
  2. package/bin/stasis.js +375 -170
  3. package/package.json +44 -11
  4. package/src/audit-corrections.js +68 -0
  5. package/src/audit.js +243 -59
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +12 -18
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +95 -128
  11. package/src/cmd/bundle.js +877 -641
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +4 -18
  16. package/src/diff.js +67 -124
  17. package/src/json-stream.js +283 -0
  18. package/src/loader-mock.js +8 -15
  19. package/src/loaders/bash.js +49 -116
  20. package/src/loaders/cargo.js +1699 -0
  21. package/src/loaders/foundry.js +923 -0
  22. package/src/loaders/paths.js +33 -0
  23. package/src/loaders/php.js +339 -394
  24. package/src/loaders/rust.js +3254 -242
  25. package/src/loaders/solidity-ownership.js +531 -0
  26. package/src/loaders/solidity.js +412 -162
  27. package/src/loaders/toml.js +68 -0
  28. package/src/metro-resolver.js +150 -0
  29. package/src/mock.js +57 -165
  30. package/src/parse.js +4 -16
  31. package/src/resolve-fields.js +331 -0
  32. package/src/resolve-node.js +416 -0
  33. package/src/resolve-typescript.js +427 -0
  34. package/src/rollup.js +5 -0
  35. package/src/sbom.js +64 -92
  36. package/src/scan.js +234 -156
  37. package/src/vfs-bundle/entries.js +140 -0
  38. package/src/vfs-bundle/github.js +193 -0
  39. package/src/vfs-bundle/soldeer.js +125 -0
  40. package/src/vfs-bundle/tree.js +606 -0
  41. package/src/vfs-bundle.js +74 -0
  42. package/src/why.js +434 -0
  43. package/src/apis/npm/index.js +0 -36
  44. package/src/apis/npm/semver.cjs +0 -44
  45. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,531 @@
1
+ // Who owns each file of a Solidity project -- the project or one of its dependencies -- decided by
2
+ // where the file really is, for the import resolution (solidity.js), forge's config discovery
3
+ // (foundry.js) and the bundler's --manifests. Dependencies are untrusted input: a link one plants
4
+ // out of itself is never followed.
5
+
6
+ import { realpathSync } from 'node:fs'
7
+ import { isAbsolute, join, parse, posix, relative, resolve, sep } from 'node:path'
8
+
9
+ import { utf8toString } from '@exodus/bytes/utf8.js'
10
+ import { LockfileError, parseGitmodules } from '@preventive/lockfile/foundry.js'
11
+ import { NO_ENTRY, readRegularFileOrNull } from '@exodus/stasis-core/bundle-util'
12
+ import { diskHost } from '@exodus/stasis-core/host'
13
+ import { hasNodeModulesSegment } from '@exodus/stasis-core/util'
14
+ import { isDir } from '../resolve-typescript.js'
15
+
16
+ // `/`-separated, as the loader's paths are: only Windows' separator is converted (on POSIX a `\\` is
17
+ // part of a name, and must not read as a directory boundary).
18
+ const toSlashes = (p) => (sep === '\\' ? p.replaceAll('\\', '/') : p)
19
+
20
+ // --- Reading --------------------------------------------------------------------------------
21
+
22
+ // `p`'s real path as `host` resolves it, which throws when it can't: on disk, as the OS does
23
+ // (realpath(3): the filesystem's own spelling, which Node's realpathSync doesn't give).
24
+ const realpathIn = (host, p) => (host === diskHost ? realpathSync.native(p) : host.realpath(p))
25
+
26
+ // `p`'s real path (realpathIn), or null.
27
+ export function realpathOrNull(p, host = diskHost) {
28
+ try {
29
+ return realpathIn(host, p)
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ // A path relative to a dir (slashes) that stays inside it.
36
+ const inRoot = (rel) => rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel)
37
+
38
+ // `abs`, a file the resolution read, relative to the project `root` (slashes): as spelled when that
39
+ // lies inside it with no `..` to resolve (a linked lib's files keep the lib's path), else by real
40
+ // paths -- where the read went (an absolute or `/proc/self/cwd` lib; a `..` after a symlink) --
41
+ // `../` when outside the project. One whose real path the OS can't give (past PATH_MAX) is never
42
+ // normalized, which could name another file: it keeps the path it was read by, `..` and all, from
43
+ // the root however that's spelled (as given or by its real path), for solidityOwnership to refuse,
44
+ // or else stays absolute, a name --manifests refuses as unresolvable. Real paths are `host`'s.
45
+ export function projectRelative(root, abs, host = diskHost) {
46
+ const rel = toSlashes(relative(root, abs))
47
+ if (inRoot(rel) && !toSlashes(abs).split('/').includes('..')) return rel
48
+ const real = realpathOrNull(abs, host)
49
+ const realRoot = realpathOrNull(root, host)
50
+ if (real !== null) return toSlashes(relative(realRoot ?? root, real))
51
+ return below(resolve(root), abs) ?? (realRoot === null ? null : below(realRoot, abs)) ?? toSlashes(abs)
52
+ }
53
+
54
+ // `abs` from `dir`, component by component as spelled (empty and `.` ones dropped, `..` kept), or
55
+ // null when it doesn't start with `dir`'s components.
56
+ function below(dir, abs) {
57
+ const parts = (p) => toSlashes(p).split('/').filter((c) => c !== '' && c !== '.')
58
+ const d = parts(dir)
59
+ const a = parts(abs)
60
+ return d.length < a.length && d.every((c, i) => a[i] === c) ? a.slice(d.length).join('/') : null
61
+ }
62
+
63
+ // realpathIn `host` of `p`: `{ real }`, or `{ real: null, missing }`, `missing` only when nothing
64
+ // is there at all. The OS may fail to resolve what is there -- a real path past PATH_MAX, a loop, a
65
+ // link whose end it can't name (`/proc/self/fd/0` on a pipe), a dir it may not search -- and a
66
+ // read may still get through.
67
+ function osRealpath(p, host) {
68
+ try {
69
+ return { real: realpathIn(host, p), missing: false }
70
+ } catch (err) {
71
+ return { real: null, missing: NO_ENTRY.has(err.code) && !lexists(p, host) }
72
+ }
73
+ }
74
+
75
+ // Whether anything is at `p` itself, a link not followed (`host.readlink` throws when nothing is).
76
+ function lexists(p, host) {
77
+ try {
78
+ host.readlink(p)
79
+ return true
80
+ } catch (err) {
81
+ if (NO_ENTRY.has(err.code)) return false
82
+ throw err
83
+ }
84
+ }
85
+
86
+ // `bytes` as UTF-8 text, a byte-order mark kept. Bytes that aren't UTF-8 throw, naming them
87
+ // `label`, rather than read with U+FFFD in their place: forge, solc and git refuse such a file, and
88
+ // the text bundled or read must be the file's own.
89
+ export function decodeUtf8(bytes, label) {
90
+ try {
91
+ return utf8toString(bytes)
92
+ } catch (err) {
93
+ throw new Error(`${label}: not valid UTF-8`, { cause: err })
94
+ }
95
+ }
96
+
97
+ // A config file's text (decodeUtf8), or null when there's no file (readRegularFileOrNull: a regular
98
+ // file only, read through `host`). Errors name it `label`.
99
+ export function readUtf8OrNull(file, label, host = diskHost) {
100
+ const buf = readRegularFileOrNull(file, label, host)
101
+ return buf === null ? null : decodeUtf8(buf, label)
102
+ }
103
+
104
+ // --- .gitmodules ------------------------------------------------------------------------------
105
+
106
+ // The submodules of the project at `baseDir`, `{ path, url, branch }` (`url` and `branch` when set),
107
+ // from its `.gitmodules` as @preventive/lockfile reads it (as git does). A url is taken as written
108
+ // (`checkUrls: false`): relative to the superproject's remote, a path or none, as it only names a
109
+ // GitHub submodule's bucket. A file the library refuses -- something git reads two ways, or that it
110
+ // doesn't check (`update = none`, `active`, a `[core]` section) -- never fails the bundle: it's
111
+ // warned about and read submodule by submodule (gitmodulesLeniently). One git itself refuses is an
112
+ // error: read past what git can't, a submodule's section would be lost, and its directory with it.
113
+ export function readGitmodules(baseDir, host = diskHost) {
114
+ const text = readUtf8OrNull(join(baseDir, '.gitmodules'), '.gitmodules', host)
115
+ if (text === null) return []
116
+ try {
117
+ return Object.values(parseGitmodules(text, { checkUrls: false }))
118
+ } catch (err) {
119
+ if (!(err instanceof LockfileError)) throw err
120
+ const { submodules, notes } = gitmodulesLeniently(text)
121
+ if (!notes.some((note) => note.startsWith(`${err.message};`))) notes.unshift(`${err.message}; reading it submodule by submodule`)
122
+ for (const note of notes) console.warn(`[loader.solidity] .gitmodules: ${note}`)
123
+ return submodules
124
+ }
125
+ }
126
+
127
+ // The keys a submodule is read for; past `path`, they're dropped in this order to read the rest.
128
+ const SUBMODULE_KEYS = new Set(['path', 'url', 'branch'])
129
+ const DROPPABLE = ['branch', 'url']
130
+
131
+ // `.gitmodules` text the library refused as a whole, read as git reads it (readGitConfig) a
132
+ // submodule at a time: a key of `submodule.<name>.<key>`, from `[submodule "name"]` or
133
+ // `[submodule.name]`, its last `path`, `url` and `branch` (the sections of one name merged), as git
134
+ // reads the checkout's .gitmodules -- a value replaces an earlier one, but for a path or url git
135
+ // ignores, one starting with `-` -- each submodule then read by the library alone. One that still
136
+ // doesn't read loses its branch, then its url; one whose path doesn't read fails closed: its
137
+ // directory, when the path names one inside the repository (`./lib/x`, `lib/x/`), is still a
138
+ // dependency, just unnamed, and else the submodule is skipped. Every other path given it is a
139
+ // dependency too: git reads the first of two where it reads .gitmodules from a commit, so no path a
140
+ // submodule is given may name the project's own code. `notes` say what was dropped.
141
+ function gitmodulesLeniently(text) {
142
+ const sections = new Map() // a submodule's name -> Map<key, its entry>
143
+ const paths = new Map() // a submodule's name -> every `path` given it
144
+ const notes = []
145
+ for (const { section, subsection, header, keys } of readGitConfig(text)) {
146
+ const variable = subsection === undefined ? section : `${section}.${subsection}`
147
+ if (!variable?.startsWith('submodule.')) continue
148
+ const name = variable.slice('submodule.'.length)
149
+ if (section !== 'submodule') notes.push(`${header}, a section git reads as [submodule "${name}"]; reading it as that`)
150
+ const kept = sections.get(name) ?? sections.set(name, new Map()).get(name)
151
+ const given = paths.get(name) ?? paths.set(name, []).get(name)
152
+ for (const entry of keys) {
153
+ if (!SUBMODULE_KEYS.has(entry.key)) continue
154
+ if (entry.key === 'path') given.push(entry.value)
155
+ if (entry.key !== 'branch' && kept.has(entry.key) && entry.value?.startsWith('-')) continue
156
+ kept.set(entry.key, entry)
157
+ }
158
+ }
159
+ const submodules = []
160
+ for (const [name, kept] of sections) {
161
+ const before = submodules.length
162
+ const header = `[submodule "${name.replaceAll(/["\\]/gu, '\\$&')}"]`
163
+ const read = () => Object.values(parseGitmodules([header, ...[...kept.values()].map((entry) => entry.text), ''].join('\n'), { checkUrls: false }))
164
+ let first = null
165
+ const dropped = []
166
+ for (;;) {
167
+ try {
168
+ submodules.push(...read())
169
+ if (first !== null) notes.push(`${first.message}; ignoring its ${dropped.join(' and ')}`)
170
+ break
171
+ } catch (err) {
172
+ if (!(err instanceof LockfileError)) throw err
173
+ first ??= err
174
+ const next = DROPPABLE.find((key) => kept.has(key))
175
+ if (next !== undefined) {
176
+ kept.delete(next)
177
+ dropped.push(next)
178
+ continue
179
+ }
180
+ const path = normalSubmodulePath(kept.get('path')?.value)
181
+ if (path === null) {
182
+ notes.push(`${first.message}; skipping the submodule`)
183
+ } else {
184
+ submodules.push({ path, url: undefined, branch: undefined })
185
+ notes.push(`${first.message}; still taking ${path} as a dependency, unnamed`)
186
+ }
187
+ break
188
+ }
189
+ }
190
+ const taken = new Set(submodules.slice(before).map((s) => s.path))
191
+ for (const path of paths.get(name).map(normalSubmodulePath)) {
192
+ if (path === null || taken.has(path)) continue
193
+ taken.add(path)
194
+ submodules.push({ path, url: undefined, branch: undefined })
195
+ notes.push(`${header}: path ${path} too, which git reads where it reads .gitmodules from a commit; still taking it as a dependency, unnamed`)
196
+ }
197
+ }
198
+ return { submodules, notes }
199
+ }
200
+
201
+ // A `path`'s value -> the directory it names, normalized, when that lies inside the repository (else
202
+ // null): `./lib/x` and `lib/x/` are lib/x.
203
+ function normalSubmodulePath(value) {
204
+ if (typeof value !== 'string') return null
205
+ const path = posix.normalize(value).replace(/\/+$/u, '')
206
+ return path !== '' && path !== '.' && inRoot(path) ? path : null
207
+ }
208
+
209
+ // git's ctype, ASCII alone: only these are space, and a key is letters, digits and `-`, starting with
210
+ // a letter.
211
+ const GIT_SPACE = new Set([' ', '\t', '\n', '\r'])
212
+ const isGitAlpha = (char) => /^[A-Za-z]$/u.test(char)
213
+ const isGitKeyChar = (char) => /^[\dA-Za-z-]$/u.test(char)
214
+ const GIT_ESCAPES = { __proto__: null, t: '\t', b: '\b', n: '\n', '\\': '\\', '"': '"' }
215
+
216
+ // `.gitmodules` text as git's config.c reads a config file, to the character, refusing what git
217
+ // refuses ("bad config line") and nothing more: its sections in order, each `{ section, subsection,
218
+ // header, keys }` -- the name lowercased, the subsection with a backslash's character for it, the
219
+ // header as written -- and each key `{ key, value, text }`: its name lowercased, its value (null for
220
+ // a key alone) and its text, from the key to the end of its value. Keys before any header are in a
221
+ // first section with no name.
222
+ function readGitConfig(text) {
223
+ const src = text.startsWith('\uFEFF') ? text.slice(1) : text // a byte-order mark git skips
224
+ let pos = 0
225
+ let last = 0 // where the character last read starts
226
+ let line = 1
227
+ let at = 1 // its line
228
+ let eof = false
229
+ // git's get_next_char: CRLF is a line end, a lone CR is space, and the end of the file a line end,
230
+ // read again at every call after.
231
+ const next = () => {
232
+ last = pos
233
+ at = line
234
+ if (pos >= src.length) {
235
+ eof = true
236
+ return '\n'
237
+ }
238
+ let char = src[pos++]
239
+ if (char === '\r' && src[pos] === '\n') char = src[pos++]
240
+ if (char === '\n') line++
241
+ return char
242
+ }
243
+ const refuse = (what) => new Error(`.gitmodules: ${what} at line ${at}; git refuses such a file`)
244
+
245
+ // git's get_base_var and get_extended_base_var: `[name]` or `[name "subsection"]`.
246
+ const readHeader = () => {
247
+ let section = ''
248
+ for (;;) {
249
+ const char = next()
250
+ if (eof) throw refuse('a section header with no closing "]"')
251
+ if (char === ']') break
252
+ if (GIT_SPACE.has(char)) return { section, subsection: readSubsection(char) }
253
+ if (!isGitKeyChar(char) && char !== '.') throw refuse("a character git doesn't take in a section name")
254
+ section += char.toLowerCase()
255
+ }
256
+ if (section === '') throw refuse('a section with no name')
257
+ return { section, subsection: undefined }
258
+ }
259
+ const readSubsection = (first) => {
260
+ let char = first
261
+ do {
262
+ if (char === '\n') throw refuse('a section header that runs past its line')
263
+ char = next()
264
+ } while (GIT_SPACE.has(char))
265
+ if (char !== '"') throw refuse('a section name and then no quoted subsection')
266
+ let subsection = ''
267
+ for (char = next(); char !== '"'; char = next()) {
268
+ if (char === '\\') char = next()
269
+ if (char === '\n') throw refuse('a subsection with no closing quote')
270
+ subsection += char
271
+ }
272
+ if (next() !== ']') throw refuse('a subsection with no "]" right after it')
273
+ return subsection
274
+ }
275
+ // git's parse_value: quotes, escapes, a `\` that runs the value on, a comment outside quotes, and
276
+ // space outside quotes trimmed at both ends.
277
+ const readValue = () => {
278
+ let value = ''
279
+ let quoted = false
280
+ let comment = false
281
+ let trim = -1 // where the space at the end begins, outside quotes
282
+ for (;;) {
283
+ let char = next()
284
+ if (char === '\n') {
285
+ if (quoted) throw refuse('a value with no closing quote')
286
+ return trim === -1 ? value : value.slice(0, trim)
287
+ }
288
+ if (comment) continue
289
+ if (GIT_SPACE.has(char) && !quoted) {
290
+ if (value !== '') {
291
+ if (trim === -1) trim = value.length
292
+ value += char
293
+ }
294
+ continue
295
+ }
296
+ if (!quoted && (char === '#' || char === ';')) {
297
+ comment = true
298
+ continue
299
+ }
300
+ trim = -1
301
+ if (char === '\\') {
302
+ char = next()
303
+ if (char === '\n') continue
304
+ if (!(char in GIT_ESCAPES)) throw refuse("an escape git doesn't read")
305
+ value += GIT_ESCAPES[char]
306
+ } else if (char === '"') {
307
+ quoted = !quoted
308
+ } else {
309
+ value += char
310
+ }
311
+ }
312
+ }
313
+ // git's get_value: a key, and `=` and its value or nothing.
314
+ const readKey = (first, start) => {
315
+ let key = first.toLowerCase()
316
+ let char = next()
317
+ for (; isGitKeyChar(char); char = next()) key += char.toLowerCase() // the end reads as a line end
318
+ while (char === ' ' || char === '\t') char = next()
319
+ let value = null
320
+ if (char !== '\n') {
321
+ if (char !== '=') throw refuse('a key and then neither "=" nor the end of its line')
322
+ value = readValue()
323
+ }
324
+ return { key, value, text: src.slice(start, last) }
325
+ }
326
+
327
+ const sections = [{ section: undefined, subsection: undefined, header: undefined, keys: [] }]
328
+ let comment = false
329
+ for (;;) {
330
+ const char = next()
331
+ const start = last
332
+ if (char === '\n') {
333
+ if (eof) return sections
334
+ comment = false
335
+ } else if (comment || GIT_SPACE.has(char)) {
336
+ continue
337
+ } else if (char === '#' || char === ';') {
338
+ comment = true
339
+ } else if (char === '[') {
340
+ const { section, subsection } = readHeader()
341
+ sections.push({ section, subsection, header: src.slice(start, pos), keys: [] })
342
+ } else if (isGitAlpha(char)) {
343
+ sections.at(-1).keys.push(readKey(char, start))
344
+ } else {
345
+ throw refuse('text where git reads a key, a section or a comment')
346
+ }
347
+ }
348
+ }
349
+
350
+ // --- Ownership --------------------------------------------------------------------------------
351
+
352
+ const readdirOrEmpty = (dir, host) => {
353
+ try {
354
+ return host.readdir(dir)
355
+ } catch {
356
+ return []
357
+ }
358
+ }
359
+
360
+ const NOTHING = { abs: null, escape: null }
361
+ // A link target's separators, as the OS reads them (a `\\` is part of a name on POSIX).
362
+ const TARGET_SEPARATORS = sep === '\\' ? /[\\/]/u : /\//u
363
+
364
+ // Who owns each project-relative path, decided from how it resolves on disk. The dependencies are
365
+ // every `node_modules/<pkg>` (`@scope/<pkg>`), each entry of the `dirs` (forge's libs, Soldeer's
366
+ // `dependencies/`; a linked entry is the dependency where it points, as a symlinked
367
+ // `lib/forge-std`), and the `packages` (git submodules). `assert(path)` throws for a path `of`
368
+ // refuses; `of(path)` gives `{ real, outside, dependency, escape }`:
369
+ // - `real`: the real path, spelled as the filesystem spells it (project-relative; null when
370
+ // nothing is there), `outside` when it's out of the root;
371
+ // - `dependency`: the real path lies in a dependency, however the path got there (a project's
372
+ // `src/vendor -> ../lib/dep/src` holds the dependency's code);
373
+ // - `escape`: `{ link, root, why }` (and `reason`, saying so) when the path may not be read: it
374
+ // crosses a symlink that no one trusted placed -- one planted inside the dependency `root` that
375
+ // leads out of it to anything but another dependency (`lib/evil/src/Evil.sol -> ../../../.env`),
376
+ // or one outside the project (`root` null) that leads back into it (a dependency linked from
377
+ // elsewhere: `lib/evil -> ../../shared/evil` holding `Evil.sol -> ../../proj/.env`) -- or
378
+ // (`why: 'unresolved'`) the walk below can't vouch for it: it resolves the path link by link, and
379
+ // where that doesn't land where the OS's realpath does (a link target it can't read as the OS
380
+ // does, one that isn't UTF-8), or the OS can't resolve it at all (a real path past PATH_MAX), the
381
+ // path is refused rather than trusted. `real` null with no `escape` means nothing is there. A
382
+ // link the project placed (a workspace package in node_modules, a linked `lib/` entry) may lead
383
+ // anywhere in the root, and so may one on the path the project was named by (a symlinked
384
+ // checkout, macOS's `/tmp`). The project is read through `host`.
385
+ export function solidityOwnership(baseDir, { dirs = [], packages = [], host = diskHost } = {}) {
386
+ const realBase = realpathIn(host, baseDir)
387
+ const realOf = (p) => realpathOrNull(p, host)
388
+ const named = resolve(baseDir)
389
+ const onNamedPath = (abs) => named === abs || named.startsWith(abs.endsWith(sep) ? abs : `${abs}${sep}`)
390
+ const toRel = (abs) => toSlashes(relative(realBase, abs)) || '.'
391
+ const inside = (rel) => rel !== '.' && inRoot(rel)
392
+ const under = (rel, dir) => rel === dir || rel.startsWith(`${dir}/`)
393
+ const clean = (d) => posix.normalize(toSlashes(d)).replace(/\/+$/u, '')
394
+
395
+ // Dirs whose entries are dependencies, and dependency dirs themselves; each by its real path too.
396
+ const holders = new Set()
397
+ const roots = new Set()
398
+ const addReal = (set, rel) => {
399
+ const real = realOf(join(baseDir, rel))
400
+ if (real !== null && inside(toRel(real))) set.add(toRel(real))
401
+ }
402
+ // A dir as the project names it: relative to the root, or (an absolute lib) by its real path.
403
+ const projectDir = (d) => {
404
+ if (!isAbsolute(d)) return clean(d)
405
+ const real = realOf(d)
406
+ return real === null ? null : toRel(real)
407
+ }
408
+ for (const d of dirs.map(projectDir).filter((rel) => rel !== null && inside(rel))) {
409
+ if (posix.basename(d) === 'node_modules') continue // a package's own rule, below
410
+ holders.add(d)
411
+ addReal(holders, d)
412
+ for (const e of readdirOrEmpty(join(baseDir, d), host)) if (e.isSymbolicLink() && isDir(join(baseDir, d, e.name), host)) addReal(roots, `${d}/${e.name}`)
413
+ }
414
+ for (const p of packages.map(clean).filter(inside)) {
415
+ roots.add(p)
416
+ addReal(roots, p)
417
+ }
418
+ const dependencyDirs = [...holders, ...roots]
419
+ const inDependency = (rel) => inside(rel) && (hasNodeModulesSegment(rel) || dependencyDirs.some((d) => under(rel, d)))
420
+ // The innermost dependency holding `rel`, a real path.
421
+ const rootOf = (rel) => {
422
+ if (!inside(rel)) return null
423
+ const parts = rel.split('/')
424
+ let best = null
425
+ const take = (r) => {
426
+ if (best === null || r.length > best.length) best = r
427
+ }
428
+ for (let i = 0; i < parts.length; i++) {
429
+ const end = i + (parts[i + 1]?.startsWith('@') ? 3 : 2)
430
+ if (parts[i] === 'node_modules' && end <= parts.length) take(parts.slice(0, end).join('/'))
431
+ }
432
+ for (const d of holders) if (rel.startsWith(`${d}/`)) take(`${d}/${rel.slice(d.length + 1).split('/')[0]}`)
433
+ for (const r of roots) if (under(rel, r)) take(r)
434
+ return best
435
+ }
436
+
437
+ // Resolve `parts` from the real dir `start` as realpath does, checking each symlink crossed
438
+ // (and those its target crosses): `{ abs, escape }`, `abs` null when nothing is there, and
439
+ // spelled as given past the last link. A link's dir and target take the filesystem's spelling (a
440
+ // case-insensitive one finds `lib` for `LIB`) before their owners are judged.
441
+ const walk = (start, parts, depth) => {
442
+ let cur = start
443
+ for (const part of parts) {
444
+ if (part === '' || part === '.') continue
445
+ if (part === '..') {
446
+ cur = parse(cur).root === cur ? cur : join(cur, '..')
447
+ continue
448
+ }
449
+ const next = join(cur, part)
450
+ let target
451
+ try {
452
+ target = host.readlink(next)
453
+ } catch {
454
+ return NOTHING
455
+ }
456
+ if (target === null) {
457
+ cur = next
458
+ continue
459
+ }
460
+ // A target that isn't UTF-8 reads with U+FFFD in it: not a name a string path can spell.
461
+ if (target.includes('\uFFFD')) return NOTHING
462
+ if (depth >= 40) return NOTHING // ELOOP
463
+ const r = walk(isAbsolute(target) ? parse(target).root : cur, target.split(TARGET_SEPARATORS), depth + 1)
464
+ if (r.abs === null || r.escape !== null) return r
465
+ const dir = realOf(cur) ?? cur
466
+ const abs = realOf(r.abs)
467
+ if (abs === null) return NOTHING
468
+ const at = toRel(join(dir, part))
469
+ const to = toRel(abs)
470
+ if (inside(at)) {
471
+ const root = rootOf(toRel(dir))
472
+ if (root !== null && !under(to, root) && !inDependency(to)) return { abs, escape: { link: at, root } }
473
+ } else if (inRoot(to) && !onNamedPath(next)) {
474
+ return { abs, escape: { link: at, root: null } }
475
+ }
476
+ cur = abs
477
+ }
478
+ return { abs: cur, escape: null }
479
+ }
480
+
481
+ const owners = new Map()
482
+ const of = (rel) => {
483
+ let owner = owners.get(rel)
484
+ if (owner === undefined) {
485
+ // As given, not normalized: the OS resolves a `..` after a link from where the link leads.
486
+ const path = rel === '' ? realBase : `${realBase}${sep}${rel}`
487
+ let { abs, escape } = walk(realBase, rel.split('/'), 0)
488
+ if (escape === null) {
489
+ // The OS's answer is the one a read gets: the walk must agree with it, or the path is
490
+ // refused, as it is when the OS can't resolve it at all, though a read may still get through.
491
+ // (Past its last link the walk's path is spelled as given; with none, it's `path` itself.)
492
+ const { real: os, missing } = osRealpath(path, host)
493
+ const walked = abs === null ? null : abs === path ? os : realOf(abs)
494
+ if (walked !== os || (os === null && !missing)) escape = { link: rel, root: null, why: 'unresolved' }
495
+ abs = os
496
+ }
497
+ const real = abs === null ? null : toRel(abs)
498
+ owner = {
499
+ real,
500
+ outside: real !== null && !inRoot(real),
501
+ dependency: real !== null && inDependency(real),
502
+ escape,
503
+ reason: escape && escapeReason(rel, escape),
504
+ }
505
+ owners.set(rel, owner)
506
+ }
507
+ return owner
508
+ }
509
+ // Throws, saying why, for a path `of` refuses; `what` names it (`'entry '`).
510
+ const assert = (rel, what = '') => {
511
+ const { reason } = of(rel)
512
+ if (reason) throw new Error(`Refusing ${what}${rel}: ${reason}`)
513
+ }
514
+ return { of, assert }
515
+ }
516
+
517
+ // The ownership of the project at `baseDir` given its lib dirs (`soldeer`: forge's `dependencies/`
518
+ // holds dependencies too), with its git submodules, which it keeps as `submodules` (readGitmodules),
519
+ // read through `host`.
520
+ export function projectOwnership(baseDir, libs, { soldeer = false, host = diskHost } = {}) {
521
+ const submodules = readGitmodules(baseDir, host)
522
+ const dirs = [...libs, ...(soldeer ? ['dependencies'] : [])]
523
+ return { ...solidityOwnership(baseDir, { dirs, packages: submodules.map((s) => s.path), host }), submodules }
524
+ }
525
+
526
+ // Why a path is refused (see solidityOwnership).
527
+ function escapeReason(path, { link, root, why }) {
528
+ if (why === 'unresolved') return `${path} crosses a link stasis can't follow the way the filesystem does`
529
+ const what = root === null ? 'a link from outside the project root back into it' : `a link out of the dependency ${root}`
530
+ return link === path ? `${path} is ${what}` : `it resolves to ${path} through ${link}, ${what}`
531
+ }