@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
package/src/babel.js ADDED
@@ -0,0 +1,131 @@
1
+ // Per-file Babel pre-transforms for `stasis build`, serving two flags:
2
+ //
3
+ // --platform=hermes downlevels every code file to the dialect Hermes (React Native's
4
+ // engine) executes -- no classes, class fields, let/const, arrows, async generators or
5
+ // for-await. Two steps per file, then a constrained final bundle:
6
+ // 1. esbuild.transform lowers class fields / private members / static blocks / `using`
7
+ // into plain class syntax (stripping TS types and compiling JSX on the way), the
8
+ // shape Babel's class transform can consume.
9
+ // 2. A Babel worker pool applies transform-block-scoping + transform-classes -- the two
10
+ // lowerings esbuild refuses to do itself.
11
+ // 3. The final esbuild bundle runs with HERMES_UNSUPPORTED (arrow/async-generator/
12
+ // for-await lowered by esbuild; class and const-and-let declared unsupported both so
13
+ // esbuild's own helpers stay Hermes-safe and as a fail-closed check that none
14
+ // survived Babel).
15
+ //
16
+ // --babel instead runs each code file through the project's own babel.config.* verbatim --
17
+ // no esbuild pre-transform, Babel sees the original source exactly as the project's own
18
+ // toolchain (e.g. Metro) would hand it over.
19
+ //
20
+ // Babel is the PROJECT's dev dependency, not stasis's: @babel/core (and, for hermes, the
21
+ // two transform plugins) resolve from the project directory, so the project pins its own
22
+ // Babel toolchain and config semantics.
23
+ import { existsSync } from 'node:fs'
24
+ import { createRequire } from 'node:module'
25
+ import { join } from 'node:path'
26
+
27
+ const require = createRequire(import.meta.url)
28
+
29
+ // esbuild `supported` map for the FINAL hermes bundle (spread into esbuild.build options).
30
+ export const HERMES_UNSUPPORTED = {
31
+ arrow: false,
32
+ class: false, // already lowered by Babel; unsupporting it doubles as a safeguard check
33
+ 'async-generator': false,
34
+ 'const-and-let': false, // ditto, and keeps esbuild-emitted helpers off let/const
35
+ 'for-await': false,
36
+ }
37
+
38
+ // Class-syntax extensions the hermes pre-transform lowers into plain classes for Babel,
39
+ // which handles none of them without extra plugins (and Hermes supports none of them either).
40
+ const PRE_TRANSFORM_UNSUPPORTED = {
41
+ 'class-field': false,
42
+ 'class-private-accessor': false,
43
+ 'class-private-brand-check': false,
44
+ 'class-private-field': false,
45
+ 'class-private-method': false,
46
+ 'class-private-static-accessor': false,
47
+ 'class-private-static-field': false,
48
+ 'class-private-static-method': false,
49
+ 'class-static-blocks': false,
50
+ 'class-static-field': false,
51
+ 'import-attributes': false,
52
+ using: false,
53
+ }
54
+
55
+ const HERMES_BABEL_PLUGINS = ['@babel/plugin-transform-block-scoping', '@babel/plugin-transform-classes']
56
+
57
+ // Only real JS-family sources are transformed; anything else the plugin serves (json, or a
58
+ // --loader override like .svg:text) passes through untouched.
59
+ const TRANSFORMABLE_LOADERS = new Set(['js', 'ts', 'jsx', 'tsx'])
60
+
61
+ // Resolved up front on the main thread so a missing install fails with instructions, not a
62
+ // worker crash mid-build. Returns absolute paths: the worker loads @babel/core by path, and
63
+ // plugin paths bypass Babel's own name resolution (which would look in the wrong dir).
64
+ function resolveFromProject(projectDir, deps, flag) {
65
+ const projectRequire = createRequire(join(projectDir, 'noop.js'))
66
+ try {
67
+ return deps.map((dep) => projectRequire.resolve(dep))
68
+ } catch (cause) {
69
+ throw new Error(
70
+ `stasis build: ${flag} needs ${deps.join(', ')} installed in the project ` +
71
+ `(e.g. \`npm i -D ${deps.join(' ')}\`); resolution failed from ${projectDir}`,
72
+ { cause }
73
+ )
74
+ }
75
+ }
76
+
77
+ // The hermes per-file pre-transform, in StasisEsbuild's load-mode `transform` hook shape:
78
+ // (source, { path, loader }) => { contents, loader } | undefined.
79
+ export function createHermesTransform({ projectDir, esbuild }) {
80
+ const [babelPath, ...plugins] = resolveFromProject(
81
+ projectDir, ['@babel/core', ...HERMES_BABEL_PLUGINS], '--platform=hermes'
82
+ )
83
+ const { transformAsync } = require('./babel-worker.cjs')
84
+ return async (source, { path, loader }) => {
85
+ if (!TRANSFORMABLE_LOADERS.has(loader)) return undefined
86
+ const { code, warnings } = await esbuild.transform(source, {
87
+ sourcemap: 'inline',
88
+ sourcefile: path,
89
+ loader,
90
+ supported: PRE_TRANSFORM_UNSUPPORTED,
91
+ })
92
+ if (warnings.length > 0) {
93
+ const formatted = await esbuild.formatMessages(warnings, { kind: 'warning', color: false })
94
+ for (const line of formatted) process.stderr.write(line)
95
+ }
96
+ const result = await transformAsync(babelPath, code, {
97
+ compact: false,
98
+ babelrc: false,
99
+ configFile: false,
100
+ plugins,
101
+ })
102
+ // TS is stripped and JSX compiled by the pre-transform, so the result is always plain js.
103
+ return { contents: result.code, loader: 'js' }
104
+ }
105
+ }
106
+
107
+ // The --babel per-file transform (same hook shape): the project's own babel.config.* over
108
+ // the original source. Its output must be esbuild-parseable js -- a config that leaves TS/JSX
109
+ // in place fails the build in Babel or esbuild, which is the project's config to fix.
110
+ export function createBabelConfigTransform({ projectDir }) {
111
+ const configFile = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json']
112
+ .map((name) => join(projectDir, name))
113
+ .find((file) => existsSync(file))
114
+ if (!configFile) {
115
+ throw new Error(`stasis build: --babel found no babel.config.(js|cjs|mjs|json) in ${projectDir}`)
116
+ }
117
+ const [babelPath] = resolveFromProject(projectDir, ['@babel/core'], '--babel')
118
+ const { transformAsync } = require('./babel-worker.cjs')
119
+ return async (source, { path, loader }) => {
120
+ if (!TRANSFORMABLE_LOADERS.has(loader)) return undefined
121
+ // filename makes per-file config (overrides, .babelrc lookup) behave as in a real run.
122
+ const result = await transformAsync(babelPath, source, {
123
+ cwd: projectDir,
124
+ root: projectDir,
125
+ configFile,
126
+ filename: path,
127
+ compact: false,
128
+ })
129
+ return { contents: result.code, loader: 'js' }
130
+ }
131
+ }
@@ -4,17 +4,12 @@ import { resolve } from 'node:path'
4
4
 
5
5
  import { Bundle } from '@exodus/stasis-core/bundle'
6
6
  import { sha512integrity } from '@exodus/stasis-core/state-util'
7
- import { moduleFileKey } from '@exodus/stasis-core/util'
7
+ import { moduleFileKey, narrowExecutable } from '@exodus/stasis-core/util'
8
8
 
9
- // Reconstruct an in-memory Bundle from a lockfile by reading every file it attests
10
- // from disk and verifying each against the lockfile's SRI digest -- the exact inverse
11
- // of `extract`'s lockfileFromBundle. A changed or tampered file fails closed here (the
12
- // digest won't match), so building from a lockfile is as trustworthy as a
13
- // `stasis run --lock=frozen` replay: the build only ever sees bytes the lockfile
14
- // vouches for. `root` is the project root the lockfile's paths are relative to.
15
- //
16
- // Directory captures (`stasis run --fs` readdir listings, format 'directory') are
17
- // skipped: they key a listing at a directory's own path, not a file esbuild could load.
9
+ // Reconstruct an in-memory Bundle from a lockfile, reading every attested file from disk and
10
+ // verifying each against the lockfile's SRI digest -- fail closed on mismatch, so this is as
11
+ // trustworthy as a `stasis run --lock=frozen` replay. `root` is what the lockfile's paths are
12
+ // relative to. Directory captures (format 'directory') are skipped -- not files esbuild can load.
18
13
  export function bundleFromLockfile(lockfile, { root }) {
19
14
  if (!lockfile.imports) throw new Error('stasis: lockfile has no recorded import graph')
20
15
  const formats = lockfile.formats ?? new Map()
@@ -32,18 +27,14 @@ export function bundleFromLockfile(lockfile, { root }) {
32
27
  } catch (cause) {
33
28
  throw new Error(`stasis: file attested by the lockfile is missing on disk: ${file}`, { cause })
34
29
  }
35
- // The lockfile digests raw on-disk bytes; verify against them before trusting
36
- // the file. (Code and 'resource' files hash identically whether viewed as a
37
- // string or bytes, since Node hashes a string as its UTF-8 bytes.)
30
+ // The lockfile digests raw on-disk bytes; verify before trusting the file.
38
31
  const actual = sha512integrity(bytes)
39
32
  if (actual !== integrity) {
40
33
  throw new Error(`stasis: ${file} does not match the lockfile (expected ${integrity}, got ${actual})`)
41
34
  }
42
- // Store content the way a Bundle does: 'resource:base64' as base64 text,
43
- // everything else (code, 'resource') as a raw UTF-8 string. A non-base64 entry is
44
- // digested AND served as UTF-8 text, so a non-UTF-8 byte would transcode lossily
45
- // (U+FFFD), diverging the served content from the verified bytes -- mirror the
46
- // capture side's isUtf8 guard and fail closed instead.
35
+ // Store content as a Bundle does: 'resource:base64' as base64, everything else as a UTF-8 string.
36
+ // A non-UTF-8 byte in a non-base64 entry would transcode lossily (U+FFFD), diverging served
37
+ // content from the verified bytes -- mirror the capture side's isUtf8 guard and fail closed.
47
38
  if (format === 'resource:base64') {
48
39
  out[rel] = bytes.toString('base64')
49
40
  } else {
@@ -61,5 +52,8 @@ export function bundleFromLockfile(lockfile, { root }) {
61
52
  modules,
62
53
  imports: lockfile.imports,
63
54
  formats,
55
+ // Narrowed to the files this bundle ended up carrying (a skipped `directory` capture drops out),
56
+ // keeping Bundle.parse's rules true for a hand-built lockfile too.
57
+ executable: narrowExecutable(lockfile.executable, { modules, formats, scope: lockfile.config.scope }),
64
58
  })
65
59
  }
@@ -0,0 +1,77 @@
1
+ import { createReadStream } from 'node:fs'
2
+ import { pipeline } from 'node:stream/promises'
3
+ import { createBrotliDecompress } from 'node:zlib'
4
+
5
+ import { Bundle } from '@exodus/stasis-core/bundle'
6
+ import { assert } from '@exodus/stasis-core/util'
7
+
8
+ import { JsonStreamParser } from './json-stream.js'
9
+
10
+ // Rejects as soon as `signal` aborts, without waiting for `promise` (onFile gets the signal to stop itself).
11
+ const untilAborted = (promise, signal) => (signal === undefined ? promise : new Promise((resolve, reject) => {
12
+ const abort = () => reject(signal.reason)
13
+ if (signal.aborted) abort()
14
+ else signal.addEventListener('abort', abort, { once: true })
15
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
16
+ }))
17
+
18
+ // Left in place of each streamed file; fromJSON's contents-free mode requires one per file.
19
+ const STREAMED = Symbol('streamed')
20
+
21
+ // Bundle.parse(brotliDecompressSync(source)), without holding the decompressed bytes or the JSON text whole.
22
+ // onFile gets each file in stream order, wherever the bundle puts its files, and before the bundle is
23
+ // validated: what it receives (format included) is provisional until readBundle resolves. See doc/file-formats.md.
24
+ export async function readBundle(source, { onFile, signal } = {}) {
25
+ assert(onFile === undefined || typeof onFile === 'function', 'readBundle: onFile must be a function')
26
+ let input = source
27
+ if (typeof source === 'string' || source instanceof URL) input = createReadStream(source)
28
+ else if (source instanceof ArrayBuffer) input = [new Uint8Array(source)]
29
+ else if (ArrayBuffer.isView(source)) input = [new Uint8Array(source.buffer, source.byteOffset, source.byteLength)]
30
+
31
+ const formats = new Map() // as far as they have streamed: newer bundles put them before any file
32
+ const delivered = new Map() // file -> the format onFile was given
33
+ const pending = []
34
+ const parser = new JsonStreamParser(onFile && {
35
+ onString(contents, path) {
36
+ if (path.length === 2 && path[0] === 'formats') formats.set(path[1] === '' ? '.' : path[1], contents)
37
+ const file = Bundle.fileKeyAt(path)
38
+ if (file === undefined) return contents
39
+ assert(!delivered.has(file), `bundle carries file '${file}' twice`)
40
+ const format = formats.get(file)
41
+ delivered.set(file, format)
42
+ pending.push([file, contents, format])
43
+ return STREAMED
44
+ },
45
+ })
46
+
47
+ // Big chunks let zlib's thread pool decompress ahead of the parser; zlib's 16 KiB default runs about 2x slower.
48
+ const decompress = createBrotliDecompress({ chunkSize: 1024 * 1024, readableHighWaterMark: 4 * 1024 * 1024 })
49
+ let drained = false
50
+ await pipeline(input, decompress, async (chunks) => {
51
+ for await (const chunk of chunks) {
52
+ parser.write(chunk)
53
+ for (const [file, contents, format] of pending.splice(0)) {
54
+ signal?.throwIfAborted() // one chunk can queue many files
55
+ // eslint-disable-next-line no-await-in-loop -- in stream order, with backpressure on the decompressor
56
+ await untilAborted(onFile(file, contents, { signal, format }), signal)
57
+ }
58
+ }
59
+ drained = true
60
+ }, { signal }).catch((error) => {
61
+ // Once drained, the stream is whole: pipeline reports unread bytes after it as an abort.
62
+ if (!drained || signal?.aborted) throw error
63
+ })
64
+
65
+ const bundle = Bundle.fromJSON(parser.end(), { contents: !onFile })
66
+ if (onFile) {
67
+ // fromJSON required a placeholder per file, so onFile got each one; it must have got nothing else.
68
+ let files = 0
69
+ for (const [, info] of bundle.modules) files += Object.keys(info.files).length
70
+ assert(delivered.size === files, 'bundle carries file contents outside its file list')
71
+ // A later `formats` key replaces the one onFile was told about.
72
+ for (const [file, format] of delivered) {
73
+ if (format !== undefined && bundle.formats.get(file) !== format) assert(false, `bundle file '${file}' changed format after onFile got it`)
74
+ }
75
+ }
76
+ return bundle
77
+ }