@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/stasis",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "File-level source bundles and lockfiles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -25,12 +25,13 @@
25
25
  "src/apis/npm/index.js",
26
26
  "src/apis/npm/semver.cjs",
27
27
  "src/audit.js",
28
+ "src/babel.js",
29
+ "src/babel-worker.cjs",
28
30
  "src/bundle.js",
29
31
  "src/bundle-from-lockfile.js",
30
32
  "src/cmd/bundle.js",
31
33
  "src/cmd/build.js",
32
34
  "src/cmd/diff.js",
33
- "src/cmd/extract.js",
34
35
  "src/cmd/sbom.js",
35
36
  "src/diff.js",
36
37
  "src/esbuild.js",
@@ -42,6 +43,7 @@
42
43
  "src/loaders/php.js",
43
44
  "src/lockfile.js",
44
45
  "src/metro.js",
46
+ "src/metro-resolver.js",
45
47
  "src/metro-transformer.js",
46
48
  "src/mock.js",
47
49
  "src/parse.js",
@@ -68,16 +70,20 @@
68
70
  },
69
71
  "homepage": "https://github.com/ExodusOSS/stasis#readme",
70
72
  "dependencies": {
71
- "@exodus/stasis-core": "1.0.0-beta.2",
72
- "@exodus/stasis-plugins": "1.0.0-beta.2",
73
+ "@exodus/stasis-core": "1.0.0-beta.3",
74
+ "@exodus/stasis-plugins": "1.0.0-beta.3",
73
75
  "oxc-parser": "^0.94.0"
74
76
  },
75
77
  "peerDependencies": {
76
- "esbuild": "*"
78
+ "esbuild": "*",
79
+ "flow-remove-types": "*"
77
80
  },
78
81
  "peerDependenciesMeta": {
79
82
  "esbuild": {
80
83
  "optional": true
84
+ },
85
+ "flow-remove-types": {
86
+ "optional": true
81
87
  }
82
88
  }
83
89
  }
@@ -10,11 +10,9 @@ const argv0 = process.argv[0] // not process.argv0 which lacks path
10
10
  function getSemver() {
11
11
  if (semver) return semver
12
12
 
13
- // Coherence check that we got Node.js path there
14
13
  assert(['node', 'node.exe'].includes(basename(argv0)))
15
14
  assert.equal(basename(argv0), basename(process.argv0))
16
15
 
17
- // Load semver from npm bundled with Node.js
18
16
  const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
19
17
  semver = require(semverDir)
20
18
  return semver
package/src/audit.js CHANGED
@@ -1,15 +1,30 @@
1
+ import { moduleFileKey } from '@exodus/stasis-core/util'
1
2
  import { advisories } from './apis/npm/index.js'
3
+ import { isEvidenceFile } from './audit-corrections.js'
2
4
  import semver from './apis/npm/semver.cjs'
3
5
  import { parseFile } from './parse.js'
6
+ import { collectWhy } from './why.js'
4
7
 
5
- // Only audit installed dependencies; workspace/first-party packages live under
6
- // non-`node_modules` keys in .modules (Lockfile/Bundle merge sources in) and
7
- // shouldn't be sent to the public registry — they leak names and produce noise.
8
+ // Only audit installed dependencies; first-party packages live under non-`node_modules` keys and
9
+ // must not be sent to the public registry (leaks names, adds noise).
10
+ //
11
+ // A package counts as present only when its REAL code is, and recorded = present:
12
+ // an artifact records exactly the files it ships or attested -- imported, entry,
13
+ // or `add`-ed (the last two have no in-edge in the resolution graph, so presence
14
+ // must NOT be derived from edges). The package is audited only if some recorded
15
+ // file is code evidence (see audit-corrections.js) -- not a corrected file (ws's
16
+ // noop browser.js stub), not a package.json manifest (recorded for resolver /
17
+ // metadata reads). So a ws shipped only as browser.js (+ manifest) is skipped,
18
+ // while one whose real code was bundled stays. Which consumers and import EDGES
19
+ // reach that code is the reason column's concern -- collectReasons and why.js
20
+ // apply the same evidence rule per file/edge there.
8
21
  export function collectPackagesFromFile(file) {
9
22
  const out = []
10
- for (const [dir, { name, version }] of parseFile(file).modules) {
23
+ for (const [dir, { name, version, files }] of parseFile(file).modules) {
11
24
  if (!dir.includes('node_modules')) continue
12
- if (name && version) out.push({ name, version })
25
+ if (!name || !version) continue
26
+ if (!Object.keys(files).some((rel) => isEvidenceFile(name, version, rel))) continue
27
+ out.push({ name, version })
13
28
  }
14
29
  return out
15
30
  }
@@ -28,9 +43,80 @@ export function collectPackages(files) {
28
43
  return out.toSorted((a, b) => a.name.localeCompare(b.name) || semver.compare(a.version, b.version))
29
44
  }
30
45
 
46
+ // Map each audited node_modules package (`name@version`) to the bundle consumers ("reasons") that
47
+ // recorded its files. Only bundles carry a reason map ({ consumer: [file, ...] }); lockfiles omit
48
+ // it. Each file is resolved back to its owning package via the module file listing. Only evidence
49
+ // files attribute (see audit-corrections.js): a consumer that recorded nothing of a package but a
50
+ // corrected file or its package.json manifest carries none of its real code and is NOT a reason --
51
+ // e.g. webpack shipping only ws's noop browser.js stub, while `run` bundles the real ws.
52
+ export function collectReasons(files) {
53
+ const byPkg = new Map()
54
+ for (const file of files) {
55
+ const artifact = parseFile(file)
56
+ const reason = artifact.reason
57
+ if (!reason) continue
58
+ const fileToPkg = new Map()
59
+ for (const [dir, { name, version, files: modFiles }] of artifact.modules) {
60
+ if (!dir.includes('node_modules') || !name || !version) continue
61
+ for (const rel of Object.keys(modFiles)) {
62
+ if (!isEvidenceFile(name, version, rel)) continue
63
+ fileToPkg.set(moduleFileKey(dir, rel), `${name}@${version}`)
64
+ }
65
+ }
66
+ for (const [consumer, list] of Object.entries(reason)) {
67
+ if (!Array.isArray(list)) continue
68
+ for (const f of list) {
69
+ const key = fileToPkg.get(f)
70
+ if (key === undefined) continue
71
+ let set = byPkg.get(key)
72
+ if (set === undefined) byPkg.set(key, (set = new Set()))
73
+ set.add(consumer)
74
+ }
75
+ }
76
+ }
77
+ return byPkg
78
+ }
79
+
31
80
  const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3, info: 4, none: 5 }
32
81
 
33
- export function flattenAdvisories(result, packages = []) {
82
+ // Consumer display order: bundler plugins (metro, webpack, esbuild, ...) first,
83
+ // then `run`, then `add`. Ties broken alphabetically.
84
+ const CONSUMER_RANK = { run: 1, add: 2 }
85
+ const consumerRank = (c) => CONSUMER_RANK[c] ?? 0
86
+ const byConsumerOrder = (a, b) => consumerRank(a) - consumerRank(b) || a.localeCompare(b)
87
+
88
+ // Build a row's `reason` cell by unioning over exactly the affected versions it
89
+ // covers. Without `--why` that's the `, `-joined set of bundle consumers; with
90
+ // `--why` it's the newline-joined `consumer: a -> b -> c` import-path lines
91
+ // (which REPLACE the consumer list). Consumers are ordered plugins -> run -> add.
92
+ // `reasonFilter`, when set, narrows the cell to a single consumer: the `--why`
93
+ // chains are already filtered upstream (see collectWhy), so only the consumer
94
+ // list needs pruning here.
95
+ function reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter) {
96
+ const parts = new Set()
97
+ const source = whyByPkg ?? reasonsByPkg
98
+ for (const v of affected) {
99
+ for (const p of source.get(`${pkg}@${v}`) ?? []) parts.add(p)
100
+ }
101
+ // --why: group `consumer: path` lines by consumer and order the groups
102
+ // plugins -> run -> add (this also re-unites a consumer's lines when they were
103
+ // split across affected versions); within a group collectWhy's compressed order
104
+ // is preserved. Otherwise: the `, `-joined consumer set in the same order.
105
+ if (whyByPkg) {
106
+ const byConsumer = new Map()
107
+ for (const line of parts) {
108
+ const i = line.indexOf(': ')
109
+ const consumer = i === -1 ? '' : line.slice(0, i)
110
+ if (!byConsumer.has(consumer)) byConsumer.set(consumer, [])
111
+ byConsumer.get(consumer).push(line)
112
+ }
113
+ return [...byConsumer.keys()].toSorted(byConsumerOrder).flatMap((c) => byConsumer.get(c)).join('\n')
114
+ }
115
+ const consumers = reasonFilter ? [...parts].filter((c) => c === reasonFilter) : [...parts]
116
+ return consumers.toSorted(byConsumerOrder).join(', ')
117
+ }
118
+
119
+ export function flattenAdvisories(result, packages = [], reasonsByPkg = new Map(), whyByPkg = null, reasonFilter = null) {
34
120
  const installed = new Map()
35
121
  for (const { name, version } of packages) {
36
122
  if (!installed.has(name)) installed.set(name, [])
@@ -45,10 +131,13 @@ export function flattenAdvisories(result, packages = []) {
45
131
  const affected = range
46
132
  ? installedVersions.filter((v) => semver.satisfies(v, range))
47
133
  : installedVersions
48
- // npm occasionally returns advisories whose vulnerable range matches none
49
- // of the versions we submitted; drop them so the table (and the CLI exit
50
- // code) reflect only real hits.
134
+ // npm sometimes returns advisories whose range matches none of the versions we submitted; drop
135
+ // them so the table and exit code reflect only real hits.
51
136
  if (installedVersions.length > 0 && affected.length === 0) continue
137
+ const reason = reasonCell(pkg, affected, reasonsByPkg, whyByPkg, reasonFilter)
138
+ // --reason keeps only advisories tied to that consumer: once the cell is
139
+ // narrowed to it, an empty cell means this package isn't related to it.
140
+ if (reasonFilter && reason === '') continue
52
141
  rows.push({
53
142
  package: pkg,
54
143
  installed: affected.join(', '),
@@ -56,6 +145,7 @@ export function flattenAdvisories(result, packages = []) {
56
145
  severity: adv.severity ?? '',
57
146
  title: adv.title ?? '',
58
147
  url: adv.url ?? '',
148
+ reason,
59
149
  })
60
150
  }
61
151
  }
@@ -69,36 +159,77 @@ export function flattenAdvisories(result, packages = []) {
69
159
  return rows
70
160
  }
71
161
 
72
- // Line breaks in npm advisory titles would otherwise break the box layout.
73
- // Covers LF, bare CR (and CRLF), and U+2028 / U+2029 line/paragraph separators.
162
+ // Strip line breaks (LF/CR/CRLF, U+2028/U+2029) from advisory titles so they can't break the box layout.
74
163
  const cell = (v) => String(v ?? '').replace(/[\r\n\u2028\u2029]+/gu, ' ')
75
164
 
76
- export function formatTable(rows, columns) {
165
+ // Split a value into the physical lines a cell occupies. Non-multiline columns
166
+ // collapse every line break to a space via `cell` (one line). A column named in
167
+ // `multiline` (the `--why` reason column) keeps intentional `\n` breaks -- one
168
+ // physical line per entry -- while still flattening stray CR/separators in each.
169
+ const cellLines = (v, multiline) =>
170
+ multiline ? String(v ?? '').split('\n').map((s) => cell(s)) : [cell(v)]
171
+
172
+ export function formatTable(rows, columns, { multiline = [] } = {}) {
77
173
  if (rows.length === 0) return ''
78
- const widths = columns.map((c) => Math.max(c.length, ...rows.map((r) => cell(r[c]).length)))
174
+ const ml = new Set(multiline)
175
+ const linesOf = (obj) => columns.map((c) => cellLines(obj[c], ml.has(c)))
176
+ const header = columns.map((c) => [c])
177
+ const body = rows.map((r) => linesOf(r))
178
+ const widths = columns.map((c, i) =>
179
+ Math.max(c.length, ...body.map((cells) => Math.max(0, ...cells[i].map((l) => l.length))))
180
+ )
79
181
  const pad = (s, w) => s.padEnd(w)
80
182
  const line = (l, m, r, fill) => l + widths.map((w) => fill.repeat(w + 2)).join(m) + r
81
- const row = (vals) => '│ ' + vals.map((v, i) => pad(cell(v), widths[i])).join(' │ ') + ' │'
183
+ // A row spans as many physical lines as its tallest cell; shorter cells pad
184
+ // out with blanks so the borders stay aligned.
185
+ const render = (cells) => {
186
+ const height = Math.max(...cells.map((c) => c.length))
187
+ const out = []
188
+ for (let i = 0; i < height; i++) {
189
+ out.push('│ ' + cells.map((c, j) => pad(c[i] ?? '', widths[j])).join(' │ ') + ' │')
190
+ }
191
+ return out.join('\n')
192
+ }
82
193
  return [
83
194
  line('┌', '┬', '┐', '─'),
84
- row(columns),
195
+ render(header),
85
196
  line('├', '┼', '┤', '─'),
86
- ...rows.map((r) => row(columns.map((c) => r[c]))),
197
+ ...body.map(render),
87
198
  line('└', '┴', '┘', '─'),
88
199
  ].join('\n')
89
200
  }
90
201
 
91
- export async function audit(files) {
202
+ export async function audit(files, { why = false, whyDeep = false, whyFull = false, reason = null } = {}) {
203
+ // --why-deep and --why-full imply --why. Deep keeps every chain instead of the
204
+ // default pruning of chains whose full suffix is already a chain (see
205
+ // collectWhy/dropSuffixed); full spells chains out with no `...` collapse.
206
+ why = why || whyDeep || whyFull
92
207
  const packages = collectPackages(files)
93
208
  if (packages.length === 0) {
94
- return { packages, advisories: {}, rows: [] }
209
+ return { packages, advisories: {}, rows: [], why }
95
210
  }
96
211
  const result = await advisories(packages)
97
- const rows = flattenAdvisories(result, packages)
98
- return { packages, advisories: result, rows }
212
+ // `--why` REPLACES the consumer list with per-consumer import paths, so only
213
+ // one of the two is computed. Restrict the (potentially expensive) path search
214
+ // to the packages that actually carry an advisory. `reason` (--reason) narrows
215
+ // both the paths (in collectWhy) and the consumer list (in flattenAdvisories)
216
+ // to a single consumer, dropping advisories unrelated to it.
217
+ let rows
218
+ if (why) {
219
+ const vulnerable = new Set(
220
+ Object.entries(result).filter(([, v]) => Array.isArray(v) && v.length > 0).map(([name]) => name)
221
+ )
222
+ const targetKeys = new Set(
223
+ packages.filter((p) => vulnerable.has(p.name)).map((p) => `${p.name}@${p.version}`)
224
+ )
225
+ rows = flattenAdvisories(result, packages, undefined, collectWhy(files, targetKeys, reason, { deep: whyDeep, full: whyFull }), reason)
226
+ } else {
227
+ rows = flattenAdvisories(result, packages, collectReasons(files), null, reason)
228
+ }
229
+ return { packages, advisories: result, rows, why, whyDeep, whyFull, reason }
99
230
  }
100
231
 
101
- export function printAuditReport({ packages, rows }, { out = process.stdout, err = process.stderr } = {}) {
232
+ export function printAuditReport({ packages, rows, why = false, reason = null }, { out = process.stdout, err = process.stderr } = {}) {
102
233
  err.write(`Scanned ${packages.length} package${packages.length === 1 ? '' : 's'}\n`)
103
234
  if (packages.length === 0) {
104
235
  err.write('No node_modules entries found in the input files\n')
@@ -108,5 +239,13 @@ export function printAuditReport({ packages, rows }, { out = process.stdout, err
108
239
  err.write('No advisories found\n')
109
240
  return
110
241
  }
111
- out.write(formatTable(rows, ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']) + '\n')
242
+ const columns = ['severity', 'package', 'installed', 'vulnerable', 'title', 'url']
243
+ // Surface the reason column only when some advisory has provenance -- bundle
244
+ // consumers, or (with --why) import paths. Under --reason WITHOUT --why every
245
+ // cell is just the filter value repeated, so drop the column then; --why still
246
+ // earns it (the import chains differ per row).
247
+ if (rows.some((r) => r.reason) && !(reason && !why)) columns.splice(3, 0, 'reason')
248
+ // Under --why the reason cell is a list of `consumer: path` lines; let it span
249
+ // multiple physical rows instead of collapsing to one.
250
+ out.write(formatTable(rows, columns, { multiline: why ? ['reason'] : [] }) + '\n')
112
251
  }
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+
3
+ const { Worker, MessageChannel, isMainThread, parentPort, workerData } = require('node:worker_threads')
4
+ const { once } = require('node:events')
5
+ const { availableParallelism } = require('node:os')
6
+
7
+ // Babel transforms are CPU-bound and `stasis build` fires many concurrently (esbuild loads
8
+ // files in parallel), so they run on a small worker-thread pool off the main thread. Workers
9
+ // are unref()ed: a pending transform keeps the process alive through its open MessagePort,
10
+ // but an idle pool never holds the process open after the build finishes.
11
+ //
12
+ // Babel is the project's dependency, not stasis's, so each pool is keyed by the resolved
13
+ // @babel/core entry path and the worker loads that exact copy (passed via workerData).
14
+ if (isMainThread) {
15
+ const maxWorkers = availableParallelism() >= 4 ? 2 : 1
16
+ const pools = new Map() // resolved @babel/core path -> [{ worker, busy }]
17
+
18
+ const getWorker = (babelPath) => {
19
+ if (!pools.has(babelPath)) pools.set(babelPath, [])
20
+ const workers = pools.get(babelPath)
21
+
22
+ const idle = workers.find((info) => info.busy === 0)
23
+ if (idle) return idle
24
+
25
+ if (workers.length < maxWorkers) {
26
+ const worker = new Worker(__filename, { workerData: { babelPath } })
27
+ worker.unref()
28
+ // unhandled top-level errors crash the process automatically, which is the desired
29
+ // behavior -- no need to listen to 'error'
30
+ workers.unshift({ worker, busy: 0 })
31
+ } else if (workers.length > 1) {
32
+ workers.sort((a, b) => a.busy - b.busy)
33
+ }
34
+
35
+ return workers[0]
36
+ }
37
+
38
+ const transformAsync = async (babelPath, code, options) => {
39
+ const info = getWorker(babelPath)
40
+ info.busy++
41
+ const channel = new MessageChannel()
42
+ info.worker.postMessage({ port: channel.port1, code, options }, [channel.port1])
43
+ const [{ result, error }] = await once(channel.port2, 'message')
44
+ info.busy--
45
+ if (error) throw error
46
+ return result
47
+ }
48
+
49
+ module.exports = { transformAsync }
50
+ } else {
51
+ const babel = require(workerData.babelPath)
52
+
53
+ parentPort.on('message', ({ port, code: input, options }) => {
54
+ try {
55
+ // transformSync: the worker is already off the main thread, async only adds overhead
56
+ const { code, map } = babel.transformSync(input, options)
57
+ // only plain data crosses the port -- a full Babel result carries non-cloneable state
58
+ port.postMessage({ result: { code, map } })
59
+ } catch (error) {
60
+ port.postMessage({ error })
61
+ }
62
+
63
+ port.close()
64
+ })
65
+ }
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
  }