@exodus/stasis 1.0.0-beta.3 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/bin/stasis.js +236 -17
- package/package.json +36 -9
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +153 -108
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +3 -5
- package/src/cmd/bundle.js +571 -376
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +3 -11
- package/src/diff.js +6 -6
- package/src/json-stream.js +283 -0
- package/src/loaders/bash.js +17 -36
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +208 -124
- package/src/loaders/rust.js +3234 -166
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -137
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +8 -23
- package/src/mock.js +3 -5
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +32 -18
- package/src/scan.js +79 -43
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -42
package/src/scan.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
2
1
|
import { extname, resolve as resolvePath, relative } from 'node:path'
|
|
3
2
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
3
|
import { createRequire, isBuiltin } from 'node:module'
|
|
5
4
|
import assert from 'node:assert/strict'
|
|
6
5
|
import { packageType } from '@exodus/stasis-core/bundle-util'
|
|
7
|
-
import { classifyExtension, classifyFormat } from '@exodus/stasis-core/util'
|
|
6
|
+
import { classifyExtension, classifyFormat, relativeEscapes } from '@exodus/stasis-core/util'
|
|
7
|
+
import { resolveTypescriptFallback } from './resolve-typescript.js'
|
|
8
|
+
import { diskHost } from '@exodus/stasis-core/host'
|
|
8
9
|
|
|
9
10
|
// Static require/import graph walker: parses source, never loads or executes user code.
|
|
10
11
|
// Dynamic specifiers (`require(name)`, `import('./'+x)`) are recorded as unresolved.
|
|
@@ -54,10 +55,10 @@ function getFlowRemoveTypes() {
|
|
|
54
55
|
// classifyFormat is the shared name->format authority (static and runtime bundles must agree); it
|
|
55
56
|
// returns null for the .js/.ts family whose module system comes from package.json `type` (null =
|
|
56
57
|
// detect from syntax, signaled up as null).
|
|
57
|
-
function formatForFile(file) {
|
|
58
|
+
function formatForFile(file, host) {
|
|
58
59
|
const format = classifyFormat(file)
|
|
59
60
|
if (format != null) return format
|
|
60
|
-
const type = packageType(file)
|
|
61
|
+
const type = packageType(file, host)
|
|
61
62
|
if (type === null) return null
|
|
62
63
|
return `${type}${extname(file) === '.ts' ? '-typescript' : ''}`
|
|
63
64
|
}
|
|
@@ -127,15 +128,22 @@ export class Scan {
|
|
|
127
128
|
// `jsx` opts the .js/.cjs/.mjs family into JSX syntax (React Native's JSX-in-.js convention);
|
|
128
129
|
// off by default because oxc, like tsc, only auto-enables JSX for .jsx/.tsx by extension.
|
|
129
130
|
// `flow`: strip Flow type syntax from JS-family sources before parsing (see #scanFile).
|
|
131
|
+
// `typescript`: retry a failed resolution with tsc's mapping (see #typescriptResolve), with
|
|
132
|
+
// `typescriptPaths` (a loadTsconfigPaths matcher) adding tsconfig alias support; only consulted
|
|
133
|
+
// on the built-in (Node) resolver -- a custom `resolve` owns its own TS handling.
|
|
130
134
|
// `resources` (a `parseResourcesOption` Set of extensions/filenames): reached files matching it
|
|
131
135
|
// are carried as opaque resources (bytes only) rather than rejected as un-carryable -- for graphs
|
|
132
136
|
// that aren't fully loadable in JS (e.g. Metro consuming .png/.svg assets).
|
|
133
|
-
|
|
137
|
+
// `host`: the filesystem the walk reads and resolves through (@exodus/stasis-core/host).
|
|
138
|
+
constructor({ conditions = [], resolve = null, jsx = false, flow = false, typescript = false, typescriptPaths = null, resources = new Set(), host = diskHost } = {}) {
|
|
134
139
|
this.extraConditions = [...conditions]
|
|
135
140
|
this.customResolve = resolve
|
|
136
141
|
this.jsx = jsx
|
|
137
142
|
this.flow = flow
|
|
143
|
+
this.typescript = typescript
|
|
144
|
+
this.typescriptPaths = typescriptPaths
|
|
138
145
|
this.resources = resources
|
|
146
|
+
this.host = host
|
|
139
147
|
// --jsx widens the script/resolvable extension sets to include .jsx/.tsx, so those files are
|
|
140
148
|
// parsed (not left as opaque leaves) and queued when reached. Off by default the base sets apply.
|
|
141
149
|
this.scriptExts = jsx ? new Set([...SCRIPT_EXTS, ...JSX_FILE_EXTS]) : SCRIPT_EXTS
|
|
@@ -154,7 +162,7 @@ export class Scan {
|
|
|
154
162
|
const queue = []
|
|
155
163
|
for (const entry of entries) {
|
|
156
164
|
const abs = resolvePath(entry)
|
|
157
|
-
assert.ok(
|
|
165
|
+
assert.ok(this.host.stat(abs) !== null, `entry not found: ${abs}`)
|
|
158
166
|
const url = pathToFileURL(abs).toString()
|
|
159
167
|
this.entries.add(url)
|
|
160
168
|
queue.push(url)
|
|
@@ -183,6 +191,28 @@ export class Scan {
|
|
|
183
191
|
this.parseErrors.push({ url, format, message, recovered })
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
// --typescript: resolve `spec` the way tsc maps TS sources, invoked only AFTER Node's own
|
|
195
|
+
// resolution missed -- so an on-disk `.js` (or any Node-resolvable target) always wins, and the
|
|
196
|
+
// mapping only completes a failed resolution, never rewrites a successful one. The rules
|
|
197
|
+
// (extension substitution, extensionless/index completion, manifest-target and tsconfig-paths
|
|
198
|
+
// mapping) live in the shared dispatcher (resolve-typescript.js), so this resolver and the
|
|
199
|
+
// legacy-field one cannot drift. The hit is realpathed to match req.resolve's canonical output
|
|
200
|
+
// (every other recorded edge is a realpath).
|
|
201
|
+
#typescriptResolve(parentFile, spec, conditions) {
|
|
202
|
+
const hit = resolveTypescriptFallback(parentFile, spec, {
|
|
203
|
+
conditions,
|
|
204
|
+
tsx: this.jsx,
|
|
205
|
+
paths: this.typescriptPaths,
|
|
206
|
+
host: this.host,
|
|
207
|
+
})
|
|
208
|
+
if (hit == null) return null
|
|
209
|
+
try {
|
|
210
|
+
return this.host.realpath(hit)
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
186
216
|
// Strip Flow type syntax to plain JS via the optional flow-remove-types dep (resolved lazily; a
|
|
187
217
|
// missing dep is an env error and propagates). Only invoked as a parse fallback (see #scanFile),
|
|
188
218
|
// so plain-JS files never reach it. Returns the rewritten source, or null when the transform
|
|
@@ -219,8 +249,8 @@ export class Scan {
|
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
// declared === null: typeless package; Node decides .js/.ts by syntax, resolved after parse.
|
|
222
|
-
const declared = formatForFile(file)
|
|
223
|
-
const src =
|
|
252
|
+
const declared = formatForFile(file, this.host)
|
|
253
|
+
const src = this.host.readFile(file).toString('utf8')
|
|
224
254
|
|
|
225
255
|
// Resolve the parser outside the try: a missing oxc-parser is an env error, not this
|
|
226
256
|
// file's — it must not be swallowed into a silent zero-edge leaf.
|
|
@@ -244,8 +274,9 @@ export class Scan {
|
|
|
244
274
|
// `-typescript` suffix; .jsx/.tsx deliberately land on plain module/commonjs (like a JSX-in-.js
|
|
245
275
|
// file) — they're bundler-transformed, keyed by extension in `stasis build`, not Node-strippable,
|
|
246
276
|
// so the "needs a JSX/TS transform" signal rides the .jsx/.tsx extension, not the format tag.
|
|
247
|
-
|
|
248
|
-
|
|
277
|
+
const tsSuffix = ext === '.ts' ? '-typescript' : ''
|
|
278
|
+
const detectedFormat = (p) => `${p.module.hasModuleSyntax ? 'module' : 'commonjs'}${tsSuffix}`
|
|
279
|
+
let format = declared ?? detectedFormat(parsed)
|
|
249
280
|
|
|
250
281
|
// oxc recovers from syntax errors (reports them in parsed.errors) rather than throwing;
|
|
251
282
|
// Warning/Advice severities aren't real parse errors.
|
|
@@ -259,7 +290,7 @@ export class Scan {
|
|
|
259
290
|
const asModule = parser.parseSync(file, src, parseOptions('module'))
|
|
260
291
|
if (nonWarning(asModule).length === 0) {
|
|
261
292
|
parsed = asModule
|
|
262
|
-
format = `module${
|
|
293
|
+
format = `module${tsSuffix}`
|
|
263
294
|
errors = []
|
|
264
295
|
}
|
|
265
296
|
} catch { /* keep the script parse and its recorded errors */ }
|
|
@@ -278,7 +309,7 @@ export class Scan {
|
|
|
278
309
|
const asFlow = parser.parseSync(file, flowSrc, parseOptions(baseSourceType))
|
|
279
310
|
if (nonWarning(asFlow).length === 0) {
|
|
280
311
|
parsed = asFlow
|
|
281
|
-
format = declared ??
|
|
312
|
+
format = declared ?? detectedFormat(parsed)
|
|
282
313
|
errors = []
|
|
283
314
|
}
|
|
284
315
|
} catch { /* keep the original parse + its errors */ }
|
|
@@ -298,19 +329,21 @@ export class Scan {
|
|
|
298
329
|
}
|
|
299
330
|
|
|
300
331
|
const specs = []
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
332
|
+
// STATEMENT-level type-ness decides an edge, matching what survives type erasure at runtime
|
|
333
|
+
// (verbatimModuleSyntax / Node's own type stripping): an `import type` / `export type`
|
|
334
|
+
// statement is erased whole, so no edge; a statement that merely lists inline `type`
|
|
335
|
+
// specifiers (`import { type A }`, `export { type B } from`) -- or none at all (`import {}`,
|
|
336
|
+
// `export {} from`) -- still loads its module, so its edge is real. oxc's module records
|
|
337
|
+
// can't draw that line (`import type { A }` and `import { type A }` yield identical
|
|
338
|
+
// all-isType entries, and `export {} from` yields no entry at all), so the statements are
|
|
339
|
+
// read off the AST, where importKind/exportKind carry it.
|
|
340
|
+
for (const node of parsed.program.body) {
|
|
341
|
+
if (node.type === 'ImportDeclaration') {
|
|
342
|
+
if (node.importKind !== 'type') specs.push({ kind: 'import', spec: node.source.value })
|
|
343
|
+
} else if (node.type === 'ExportNamedDeclaration') {
|
|
344
|
+
if (node.source != null && node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
|
|
345
|
+
} else if (node.type === 'ExportAllDeclaration') {
|
|
346
|
+
if (node.exportKind !== 'type') specs.push({ kind: 'export-from', spec: node.source.value })
|
|
314
347
|
}
|
|
315
348
|
}
|
|
316
349
|
findCallSpecifiers(parsed.program, (s) => specs.push(s))
|
|
@@ -320,12 +353,17 @@ export class Scan {
|
|
|
320
353
|
// IMPORT conditions -- the parent-format set baked the wrong `exports` branch into the bundle.
|
|
321
354
|
// The custom (legacy-field) resolver keeps the format set: bundlers don't split per edge.
|
|
322
355
|
const formatContext = ['module', 'module-typescript'].includes(format) ? 'import' : 'require'
|
|
323
|
-
const req = createRequire(file)
|
|
324
356
|
const edges = []
|
|
325
357
|
const specMaps = new Map() // condition key -> specifier -> child URL
|
|
326
|
-
const
|
|
358
|
+
const addChild = (s, key, childURL, childPath) => {
|
|
359
|
+
edges.push({ ...s, child: childURL })
|
|
327
360
|
if (!specMaps.has(key)) specMaps.set(key, new Map())
|
|
328
|
-
specMaps.get(key).set(spec, childURL)
|
|
361
|
+
specMaps.get(key).set(s.spec, childURL)
|
|
362
|
+
if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(childURL)
|
|
363
|
+
}
|
|
364
|
+
const addUnresolved = (s, reason) => {
|
|
365
|
+
edges.push({ ...s, error: reason })
|
|
366
|
+
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason })
|
|
329
367
|
}
|
|
330
368
|
|
|
331
369
|
for (const s of specs) {
|
|
@@ -346,13 +384,9 @@ export class Scan {
|
|
|
346
384
|
} else if (r?.empty) {
|
|
347
385
|
edges.push({ ...s, empty: true })
|
|
348
386
|
} else if (r?.url) {
|
|
349
|
-
|
|
350
|
-
record(key, s.spec, r.url)
|
|
351
|
-
const childPath = fileURLToPath(r.url)
|
|
352
|
-
if (this.resolvableExts.has(extname(childPath)) || this.#isResource(childPath)) queue.push(r.url)
|
|
387
|
+
addChild(s, key, r.url, fileURLToPath(r.url))
|
|
353
388
|
} else {
|
|
354
|
-
|
|
355
|
-
this.unresolved.push({ parentURL: url, kind: s.kind, spec: s.spec, reason: 'MODULE_NOT_FOUND' })
|
|
389
|
+
addUnresolved(s, 'MODULE_NOT_FOUND')
|
|
356
390
|
}
|
|
357
391
|
continue
|
|
358
392
|
}
|
|
@@ -360,17 +394,19 @@ export class Scan {
|
|
|
360
394
|
edges.push({ ...s, builtin: true })
|
|
361
395
|
continue
|
|
362
396
|
}
|
|
397
|
+
let childPath
|
|
363
398
|
try {
|
|
364
|
-
|
|
365
|
-
const childURL = pathToFileURL(childPath).toString()
|
|
366
|
-
edges.push({ ...s, child: childURL })
|
|
367
|
-
record(key, s.spec, childURL)
|
|
368
|
-
const childExt = extname(childPath)
|
|
369
|
-
if (this.resolvableExts.has(childExt) || this.#isResource(childPath)) queue.push(childURL)
|
|
399
|
+
childPath = this.host.resolve(file, s.spec, conditions)
|
|
370
400
|
} catch (cause) {
|
|
371
|
-
|
|
372
|
-
|
|
401
|
+
// --typescript: complete the miss with tsc's mapping (see #typescriptResolve); the edge
|
|
402
|
+
// stays keyed by the ORIGINAL specifier -- only the target is the mapped file.
|
|
403
|
+
if (this.typescript) childPath = this.#typescriptResolve(file, s.spec, conditions)
|
|
404
|
+
if (childPath == null) {
|
|
405
|
+
addUnresolved(s, cause.code ?? cause.message)
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
373
408
|
}
|
|
409
|
+
addChild(s, key, pathToFileURL(childPath).toString(), childPath)
|
|
374
410
|
}
|
|
375
411
|
|
|
376
412
|
for (const [key, specMap] of specMaps) {
|
|
@@ -384,7 +420,7 @@ export class Scan {
|
|
|
384
420
|
const rel = (url) => {
|
|
385
421
|
const abs = fileURLToPath(url)
|
|
386
422
|
const r = relative(root, abs)
|
|
387
|
-
if (r
|
|
423
|
+
if (relativeEscapes(r)) throw new Error(`Path outside root: ${abs}`)
|
|
388
424
|
return r
|
|
389
425
|
}
|
|
390
426
|
const relEdge = (e) => (e.child ? { ...e, child: rel(e.child) } : e)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { join, normalize, relative } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import { readJson } from '@exodus/stasis-core/bundle-util'
|
|
5
|
+
import { isAutoExcludedDir, isPlainObject, posixPathEscapes, relativeEscapes } from '@exodus/stasis-core/util'
|
|
6
|
+
import { Vfs } from '@preventive/vfs'
|
|
7
|
+
import { checkVfsOptions, fieldResolverFor } from '../cmd/bundle.js'
|
|
8
|
+
import { foundrySourceDir } from '../loaders/foundry.js'
|
|
9
|
+
import { resolveTypescriptFallback, typescriptExportsTarget } from '../resolve-typescript.js'
|
|
10
|
+
import { checkKind, checkVersion, packageManagerOf, vfsHost } from './tree.js'
|
|
11
|
+
|
|
12
|
+
const JS = /\.[cm]?[jt]s$/u
|
|
13
|
+
// Node's own conditions for require() and for import, which the build's are added to.
|
|
14
|
+
const NODE_CONDITIONS = [['require', 'node', 'node-addons', 'module-sync'], ['import', 'node', 'node-addons', 'module-sync']]
|
|
15
|
+
|
|
16
|
+
// The entry points the package.json in `dir` names, as paths from `dir`, resolved as the JS build
|
|
17
|
+
// with the given options resolves: as Node does, `conditions` added, or with `mainFields` or
|
|
18
|
+
// `metro` through the build's field resolver, once per platform of `platforms` under `metro`; what
|
|
19
|
+
// that misses mapped as tsc maps it under `typescript`, as the build's fallback maps it. In
|
|
20
|
+
// that order: its own entry, as the build resolves `./` there (`main`, or the first of the main
|
|
21
|
+
// fields, else index); each subpath `exports` holds (but a pattern), as the package's name resolves
|
|
22
|
+
// for require() and for import, with the conditions the build adds (the RN ones under `metro`); and
|
|
23
|
+
// each `bin`. Only JS files in `dir` that are there, named from within it; none without a package.json.
|
|
24
|
+
function packageEntries(host, dir, { conditions = [], mainFields, metro = false, platforms = [], jsx = false, typescript = false } = {}) {
|
|
25
|
+
const real = host.realpath(dir)
|
|
26
|
+
const manifest = join(real, 'package.json')
|
|
27
|
+
const pkg = readJson(manifest, host)
|
|
28
|
+
if (!isPlainObject(pkg)) return []
|
|
29
|
+
const found = new Set()
|
|
30
|
+
const add = (file) => {
|
|
31
|
+
const rel = file === undefined ? '' : relative(real, file)
|
|
32
|
+
if (rel !== '' && !relativeEscapes(rel) && JS.test(rel)) found.add(rel)
|
|
33
|
+
}
|
|
34
|
+
const passes = metro || mainFields !== undefined
|
|
35
|
+
? (metro ? platforms : [null]).map((platform) => fieldResolverFor(platform, { mainFields, metro, conditions, jsx, typescript, host }))
|
|
36
|
+
: [{ extras: conditions, mainFields: ['main'] }]
|
|
37
|
+
// Node's resolution of `specifier`, else under `typescript` the miss `mapped` as tsc maps it; a
|
|
38
|
+
// real path either way, or undefined.
|
|
39
|
+
const viaNode = (specifier, names, extras, mapped) => {
|
|
40
|
+
const set = new Set([...names, ...extras])
|
|
41
|
+
try {
|
|
42
|
+
return host.resolve(manifest, specifier, set)
|
|
43
|
+
} catch {
|
|
44
|
+
const hit = typescript ? mapped(set) : null
|
|
45
|
+
return hit === null ? undefined : host.realpath(hit)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const { extras, mainFields: fields, resolver } of passes) {
|
|
49
|
+
const entry = fields.map((name) => pkg[name]).find((value) => typeof value === 'string' && value !== '')
|
|
50
|
+
if (entry !== undefined && posixPathEscapes(entry)) continue
|
|
51
|
+
if (resolver === undefined) {
|
|
52
|
+
add(viaNode('./', NODE_CONDITIONS[0], extras, (set) => resolveTypescriptFallback(manifest, './', { conditions: set, tsx: jsx, host })))
|
|
53
|
+
} else {
|
|
54
|
+
// A real path, as Node's resolution gives and the scan takes.
|
|
55
|
+
const hit = resolver(manifest, './')
|
|
56
|
+
if (hit?.url !== undefined) add(host.realpath(fileURLToPath(hit.url)))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const { exports, name } = pkg
|
|
60
|
+
const keyed = isPlainObject(exports) && Object.keys(exports).some((key) => key.startsWith('.'))
|
|
61
|
+
const subpaths = keyed ? Object.keys(exports) : exports === undefined || exports === null ? [] : ['.']
|
|
62
|
+
for (const subpath of typeof name === 'string' ? subpaths : []) {
|
|
63
|
+
if (!subpath.startsWith('.') || subpath.includes('*') || subpath.endsWith('/')) continue
|
|
64
|
+
const specifier = subpath === '.' ? name : `${name}${subpath.slice(1)}`
|
|
65
|
+
for (const { extras } of passes) {
|
|
66
|
+
for (const names of NODE_CONDITIONS) add(viaNode(specifier, names, extras, (set) => typescriptExportsTarget(real, exports, subpath, { conditions: set, tsx: jsx, host })))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const bins = typeof pkg.bin === 'string' ? [pkg.bin] : pkg.bin !== null && typeof pkg.bin === 'object' ? Object.values(pkg.bin) : []
|
|
70
|
+
for (const bin of bins) {
|
|
71
|
+
if (typeof bin === 'string' && !posixPathEscapes(bin) && host.stat(join(real, bin))?.isFile()) add(host.realpath(join(real, bin)))
|
|
72
|
+
}
|
|
73
|
+
return [...found]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Directories whose .sol files are none of a project's entry points: its tests, scripts and mocks,
|
|
77
|
+
// what it depends on or builds, and what no walk descends into.
|
|
78
|
+
const SKIPPED_DIRS = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'lib', 'node_modules', 'dependencies', 'out', 'cache', 'artifacts', 'build'])
|
|
79
|
+
const isSkippedDir = (name) => SKIPPED_DIRS.has(name.toLowerCase()) || isAutoExcludedDir(name)
|
|
80
|
+
// A .sol file but a test or script by its name.
|
|
81
|
+
const isEntrySol = (entry) => entry.isFile() && entry.name.endsWith('.sol') && !/\.(?:t|s|test|spec)\.sol$/u.test(entry.name)
|
|
82
|
+
|
|
83
|
+
// The .sol files under the directory `rel` of `real` that are entry points by name, but those in a
|
|
84
|
+
// skipped directory; sorted. Links are not followed.
|
|
85
|
+
function solidityFiles(host, real, rel) {
|
|
86
|
+
const out = []
|
|
87
|
+
const walk = (sub) => {
|
|
88
|
+
for (const entry of host.readdir(join(real, sub))) {
|
|
89
|
+
const path = sub === '.' ? entry.name : `${sub}/${entry.name}`
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!isSkippedDir(entry.name)) walk(path)
|
|
92
|
+
} else if (isEntrySol(entry)) {
|
|
93
|
+
out.push(path)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
walk(rel)
|
|
98
|
+
return out.toSorted()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The entry points of the Soldeer project in `dir`, by name and layout, as paths from `dir`: its
|
|
102
|
+
// .sol files directly in it, then those under contracts/, then those under its source directory
|
|
103
|
+
// (its foundry.toml's default profile's `src`, else `src`); none of its tests, scripts or mocks,
|
|
104
|
+
// nor any in what it depends on or builds. A link to a directory is none.
|
|
105
|
+
function solidityEntries(host, dir) {
|
|
106
|
+
const real = host.realpath(dir)
|
|
107
|
+
const isDirectory = (rel) => !posixPathEscapes(rel) && host.stat(join(real, rel))?.isDirectory() && host.readlink(join(real, rel)) === null
|
|
108
|
+
const under = (rel) => (isDirectory(rel) ? solidityFiles(host, real, rel) : [])
|
|
109
|
+
const src = normalize(foundrySourceDir(real, { host })).replace(/\/+$/u, '')
|
|
110
|
+
const own = host.readdir(real).filter(isEntrySol).map((entry) => entry.name).toSorted()
|
|
111
|
+
return [...new Set([...own, ...under('contracts'), ...(src === 'contracts' ? [] : under(src))])]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// By the kind of bundle a package manager builds: an entry its options are checked over before
|
|
115
|
+
// there are any, the entries the build takes where none are given, and what having none says.
|
|
116
|
+
export const KINDS = {
|
|
117
|
+
js: { standIn: 'index.js', entries: packageEntries, none: 'has no package.json naming a JS entry point there' },
|
|
118
|
+
sol: { standIn: 'index.sol', entries: solidityEntries, none: 'has no .sol entry point directly in it, under contracts/, or under its source directory' },
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const EMPTY = vfsHost(new Vfs())
|
|
122
|
+
|
|
123
|
+
// The build's checks of `options` before anything is fetched, over an empty tree, which never
|
|
124
|
+
// decides them: for the package manager given, else for any; over the entries given, else over one
|
|
125
|
+
// of the kind the default ones are. -> the PACKAGE_MANAGERS entry of the one given
|
|
126
|
+
export function checkAhead(name, packageManager, options) {
|
|
127
|
+
checkVersion(name, { packageManager, ...options })
|
|
128
|
+
const pm = packageManager === undefined ? undefined : packageManagerOf(name, packageManager)
|
|
129
|
+
const refusal = (entries) => {
|
|
130
|
+
try {
|
|
131
|
+
checkKind(name, checkVfsOptions(name, { ...options, entries, cwd: '/', host: EMPTY, fetched: false }), packageManager === undefined ? undefined : [packageManager])
|
|
132
|
+
return null
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const refusals = options.entries === undefined ? (pm === undefined ? Object.values(KINDS) : [KINDS[pm.kind]]).map(({ standIn }) => refusal([standIn])) : [refusal(options.entries)]
|
|
138
|
+
if (!refusals.includes(null)) throw refusals[0]
|
|
139
|
+
return pm
|
|
140
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { isValidRepoField } from '@exodus/stasis-core/bundle'
|
|
4
|
+
import { posixPathEscapes } from '@exodus/stasis-core/util'
|
|
5
|
+
import { decompress } from '@preventive/archive/compression.js'
|
|
6
|
+
import { ArchiveError, unpack } from '@preventive/archive/tar.js'
|
|
7
|
+
import { createClient } from '@preventive/upstream/github.js'
|
|
8
|
+
import { vfsFromEntries } from '@preventive/vfs'
|
|
9
|
+
import { buildVfsBundle } from '../cmd/bundle.js'
|
|
10
|
+
import { KINDS, checkAhead } from './entries.js'
|
|
11
|
+
import { checkTarget, detectPackageManager, installedAlone, lockfileOf, lockfileRoot, lockfilesListed, noLockfile, packageManagerOf, vfsHost } from './tree.js'
|
|
12
|
+
|
|
13
|
+
// As upstream's tree verification bounds a tarball's unpacked size.
|
|
14
|
+
const MAX_TAR_BYTES = 2 ** 30
|
|
15
|
+
// What a git tree holds (upstream's verification refuses anything else from GitHub).
|
|
16
|
+
const TREE_TYPES = new Set(['file', 'directory', 'symlink'])
|
|
17
|
+
// Lockfile references to a path above the lockfile's directory (conservatively: any importer's):
|
|
18
|
+
// pnpm's and yarn's specs, and package-lock.json's package keys and `resolved` paths, `..` itself
|
|
19
|
+
// (`link:..`, npm's `".."`) or under it.
|
|
20
|
+
const LOCKFILE_ESCAPE = /(?:link:|file:|directory: |["'])\.\.(?![^/"'\s])/u
|
|
21
|
+
const TSCONFIG = /(?:^|\/)[jt]sconfig[^/]*\.json$/u
|
|
22
|
+
|
|
23
|
+
// Whether `target`, a path relative to the file `from`, resolves outside the tree.
|
|
24
|
+
const resolvesOutside = (from, target) => target.startsWith('/') || posixPathEscapes(posix.join(posix.dirname(from), target))
|
|
25
|
+
|
|
26
|
+
// The mode a checkout gives an entry under the usual umask 022. Git keeps only a file's executable
|
|
27
|
+
// bit (upstream verifies that much), and GitHub's tarballs are written with git archive's tar.umask
|
|
28
|
+
// 0002, which makes them 664 and 775 besides.
|
|
29
|
+
const checkoutMode = ({ type, mode }) => (type === 'symlink' ? mode : type === 'directory' || mode & 0o100 ? 0o755 : 0o644)
|
|
30
|
+
|
|
31
|
+
// A GitHub tarball's entries, its one top directory dropped, with the modes a checkout has.
|
|
32
|
+
async function treeEntries(tarball, where) {
|
|
33
|
+
const entries = []
|
|
34
|
+
for (const entry of unpack(await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }))) {
|
|
35
|
+
const name = entry.name.slice(entry.name.indexOf('/') + 1)
|
|
36
|
+
if (!TREE_TYPES.has(entry.type)) throw new Error(`${where}: unexpected ${entry.type} ${JSON.stringify(name)} in the tarball`)
|
|
37
|
+
if (entry.name.includes('/')) entries.push({ ...entry, name, mode: checkoutMode(entry) })
|
|
38
|
+
}
|
|
39
|
+
return entries
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The first symlink pointing outside the tree, which would otherwise resolve within it.
|
|
43
|
+
const escapingLink = (entries) => entries.find((entry) => entry.type === 'symlink' && resolvesOutside(entry.name, entry.linkname))
|
|
44
|
+
|
|
45
|
+
// Whether a subtree's files refer to a path above it: a lockfile link or a [jt]sconfig path.
|
|
46
|
+
function refersAbove(entries, lockfile) {
|
|
47
|
+
const text = (entry) => new TextDecoder().decode(entry.data)
|
|
48
|
+
return entries.some((entry) => entry.type === 'file' && (entry.name === lockfile
|
|
49
|
+
? LOCKFILE_ESCAPE.test(text(entry))
|
|
50
|
+
: TSCONFIG.test(entry.name) && [...text(entry).matchAll(/"(\.\.?\/[^"]*)"/gu)].some(([, path]) => resolvesOutside(entry.name, path))))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The directories above `directory`, the repo's root (undefined) last.
|
|
54
|
+
function ancestorsOf(directory) {
|
|
55
|
+
const out = []
|
|
56
|
+
for (let dir = posix.dirname(directory); dir !== '.'; dir = posix.dirname(dir)) out.push(dir)
|
|
57
|
+
return [...out, undefined]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The files of the subtree `tree`, or null where one is a symlink out of it.
|
|
61
|
+
async function treeFiles(client, { github, tree, where }) {
|
|
62
|
+
let files
|
|
63
|
+
try {
|
|
64
|
+
files = await treeEntries(await client.getRepoTreeTarball({ repo: github, tree }), where)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof ArchiveError) return null // e.g. a symlink out of the subtree
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
return escapingLink(files) ? null : files
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The whole repo's files at `sha`.
|
|
73
|
+
async function repoFiles(client, { github, sha, where }) {
|
|
74
|
+
const files = await treeEntries(await client.getRepoTarball({ repo: github, sha }), where)
|
|
75
|
+
const link = escapingLink(files)
|
|
76
|
+
if (link) throw new Error(`${where}: symlink ${JSON.stringify(link.name)} points outside the repo`)
|
|
77
|
+
return files
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Checked as the Bundle checks them, before anything is fetched; a tag's name is upstream's to check.
|
|
81
|
+
function checkRepo(name, { github, sha, tag, directory }) {
|
|
82
|
+
if (github === undefined) throw new Error(`${name}: github is required`)
|
|
83
|
+
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) throw new TypeError(`${name}: tag must be a non-empty string`)
|
|
84
|
+
if (sha !== undefined && tag !== undefined) throw new Error(`${name}: sha and tag both name the commit: give one`)
|
|
85
|
+
for (const [key, value] of Object.entries({ github, commit: sha, directory: directory || undefined })) {
|
|
86
|
+
if (value !== undefined && !isValidRepoField(key, value)) throw new Error(`${name}: invalid ${key}: ${JSON.stringify(value)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// listRepoDir's listing of a directory of the repo at `sha`, each listed once.
|
|
91
|
+
function lister(client, { github, sha }) {
|
|
92
|
+
const listings = new Map()
|
|
93
|
+
return (directory) => {
|
|
94
|
+
if (!listings.has(directory)) listings.set(directory, client.listRepoDir({ repo: github, sha, directory }))
|
|
95
|
+
return listings.get(directory)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Of no package manager given, the one whose lockfile alone `directory` (or the repo's root) and
|
|
100
|
+
// the directories above it list, which is then the only one that may install it; refused where
|
|
101
|
+
// none does; undefined where more than one does, or the listings can't tell, for the tree to tell.
|
|
102
|
+
async function listedPackageManager(list, { directory, where }) {
|
|
103
|
+
let listings
|
|
104
|
+
try {
|
|
105
|
+
listings = await Promise.all((directory ? [directory, ...ancestorsOf(directory)] : [undefined]).map(list))
|
|
106
|
+
} catch {
|
|
107
|
+
// No plain directory in git (a symlink, or under one); the root's failure is GitHub's.
|
|
108
|
+
await list(undefined)
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
const listed = lockfilesListed(listings)
|
|
112
|
+
if (listed.length === 0) throw noLockfile(where, posix.resolve('/', directory || '.'))
|
|
113
|
+
return listed.length === 1 ? listed[0] : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The files of `directory` alone, when it holds the lockfile, is installed from itself and stands
|
|
117
|
+
// alone; else null.
|
|
118
|
+
async function subtreeEntries(client, list, { github, directory, packageManager, where }) {
|
|
119
|
+
const names = async (path) => (await list(path)).map((entry) => entry.path)
|
|
120
|
+
let listing
|
|
121
|
+
try {
|
|
122
|
+
listing = await list(directory)
|
|
123
|
+
} catch {
|
|
124
|
+
return null // no plain directory in git (a symlink, or under one): the whole repo resolves it
|
|
125
|
+
}
|
|
126
|
+
if (!lockfilesListed([listing]).includes(packageManager)) return null
|
|
127
|
+
const above = ancestorsOf(directory)
|
|
128
|
+
// Installed from a root above it (a workspace's), it is built from there.
|
|
129
|
+
if (!(await installedAlone(packageManager, listing.map((entry) => entry.path), () => Promise.all(above.map(names))))) return null
|
|
130
|
+
// Its tree id, as the listing above it holds it, held to that directory's own.
|
|
131
|
+
const tree = (await list(above[0])).find((entry) => entry.path === posix.basename(directory) && entry.type === 'tree')?.sha
|
|
132
|
+
const files = tree === undefined ? null : await treeFiles(client, { github, tree, where })
|
|
133
|
+
return files === null || refersAbove(files, lockfileOf(packageManager)) ? null : files
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The repo as buildGitHubBundle downloads it, for `name`: at `sha`, else the commit `tag` names (an
|
|
137
|
+
// annotated tag followed to it), else the default branch's head; `directory` alone where it stands
|
|
138
|
+
// alone for the package manager installing it, else the whole repo. That package manager is
|
|
139
|
+
// `packageManager` if given, else the one the listings tell, before the tree is fetched, else the
|
|
140
|
+
// tree; `options` are checked for it once it is known.
|
|
141
|
+
// -> { vfs, host, cwd, subtree, sha, where, packageManager, kind }
|
|
142
|
+
async function repoTree(name, { github, sha, tag, directory, client, packageManager }, options) {
|
|
143
|
+
checkAhead(name, packageManager, options)
|
|
144
|
+
checkRepo(name, { github, sha, tag, directory })
|
|
145
|
+
client ??= createClient({ token: null })
|
|
146
|
+
sha ??= tag === undefined ? (await client.getRepoHead({ repo: github })).oid : (await client.getRepoTag({ repo: github, tag })).oid
|
|
147
|
+
const where = `${name}: ${github}@${sha}`
|
|
148
|
+
const list = lister(client, { github, sha })
|
|
149
|
+
let found = packageManager
|
|
150
|
+
if (found === undefined) {
|
|
151
|
+
found = await listedPackageManager(list, { directory, where })
|
|
152
|
+
if (found !== undefined) checkAhead(name, found, options)
|
|
153
|
+
}
|
|
154
|
+
const subtree = directory && found !== undefined ? await subtreeEntries(client, list, { github, directory, packageManager: found, where }) : null
|
|
155
|
+
const vfs = vfsFromEntries(subtree ?? await repoFiles(client, { github, sha, where }))
|
|
156
|
+
const host = vfsHost(vfs)
|
|
157
|
+
const cwd = posix.resolve('/', subtree ? '.' : directory ?? '.')
|
|
158
|
+
if (packageManager === undefined) {
|
|
159
|
+
const detected = detectPackageManager(where, host, cwd, { os: options.os })
|
|
160
|
+
if (detected !== found) checkAhead(name, detected, options)
|
|
161
|
+
found = detected
|
|
162
|
+
}
|
|
163
|
+
return { vfs, host, cwd, subtree, sha, where, packageManager: found, kind: KINDS[packageManagerOf(name, found).kind] }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A GitHub repo at a full commit, or the one `tag` names, the default branch's head without either,
|
|
167
|
+
// as buildVfsBundle builds it: `directory` downloaded alone where it stands alone, else the whole
|
|
168
|
+
// repo, and without a `packageManager`, the one whose lockfile installs it, where only one's does
|
|
169
|
+
// (repoTree). Without `entries`, they are the ones suggestedEntries suggests. `repo` names the
|
|
170
|
+
// commit and where the lockfile is, which the bundle's paths are relative to. Nothing is read from
|
|
171
|
+
// disk: the tree's bytes come from GitHub, or from the cache setCacheDir names, held to the git tree
|
|
172
|
+
// id either way (@preventive/upstream), and are unpacked into a Vfs that buildVfsBundle reads alone.
|
|
173
|
+
export async function buildGitHubBundle({ github, sha, tag, directory, client, packageManager, ...options } = {}) {
|
|
174
|
+
checkTarget('buildGitHubBundle', options)
|
|
175
|
+
const tree = await repoTree('buildGitHubBundle', { github, sha, tag, directory, client, packageManager }, options)
|
|
176
|
+
const root = lockfileRoot(tree.host, tree.packageManager, tree.cwd, options.os)
|
|
177
|
+
const at = tree.subtree ? posix.join(directory, (root ?? '/').slice(1)) : (root ?? '/').slice(1)
|
|
178
|
+
const location = at === '' ? { root: true } : isValidRepoField('directory', at) ? { directory: at } : {}
|
|
179
|
+
let { entries } = options
|
|
180
|
+
if (entries === undefined) {
|
|
181
|
+
// As suggestedEntries suggests them, from the tree at hand.
|
|
182
|
+
entries = tree.kind.entries(tree.host, tree.cwd, options)
|
|
183
|
+
if (entries.length === 0) throw new Error(`${tree.where}: no entries given, and ${directory || 'the repo root'} ${tree.kind.none}`)
|
|
184
|
+
}
|
|
185
|
+
return buildVfsBundle({ ...options, entries, packageManager: tree.packageManager, vfs: tree.vfs, cwd: tree.cwd, repo: { github, commit: tree.sha, ...location } })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// suggestedEntries of a GitHub repo, `{ github, sha, tag, directory, client, packageManager }`, as
|
|
189
|
+
// buildGitHubBundle downloads it.
|
|
190
|
+
export async function suggestedRepoEntries(repo, resolution) {
|
|
191
|
+
const tree = await repoTree('suggestedEntries', repo, resolution)
|
|
192
|
+
return tree.kind.entries(tree.host, tree.cwd, resolution)
|
|
193
|
+
}
|