@exodus/stasis 1.0.0-alpha.3 → 1.0.0-beta.0

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.
@@ -0,0 +1,336 @@
1
+ // Rust loader: produces a `{ sources, resolutions, missing }` triple from a
2
+ // crate's source files, mirroring the Solidity/Bash loaders' interface. The
3
+ // reachable set is discovered by following `mod foo;` declarations (the only
4
+ // thing that pulls a new file into a crate); `use crate::path` statements
5
+ // don't add files, they just reference already-declared modules, so they're
6
+ // resolved against the module tree for the import graph but never widen the walk.
7
+ //
8
+ // `use crate::` resolution is best-effort (external crates, macro paths, and
9
+ // item-vs-module ambiguity are simply dropped). An unconditional `mod foo;`
10
+ // with no backing file is a genuine hole (a compile error in a real crate) and
11
+ // is reported in `missing` so the bundle builder can reject it; a cfg-gated
12
+ // `mod` (e.g. `#[cfg(test)] mod tests;`) may be compiled out and is tolerated.
13
+ // Comments are stripped before extraction so a commented-out `mod`/`use` is
14
+ // neither followed nor flagged.
15
+
16
+ import { realpathSync, statSync } from 'node:fs'
17
+ import { readFile } from 'node:fs/promises'
18
+ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'
19
+
20
+ import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
21
+
22
+ // `mod foo;` / `pub mod foo;` / `pub(crate) mod foo;` — external module
23
+ // declarations only. Inline `mod foo { ... }` is intentionally NOT matched.
24
+ // Applied per (trimmed) line, so it stays anchored at the line start.
25
+ const MOD_DECL_RE = /^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;/u
26
+
27
+ // `crate::path::to::item` references (in `use` or expression position).
28
+ const USE_CRATE_RE = /\bcrate(?:::\w+)+/gu
29
+
30
+ // `cargo vendor` copies external crates in-tree under this directory; their
31
+ // sources are pulled into the bundle and tagged `cargo` (see cmd/bundle.js).
32
+ const VENDOR_DIR = 'vendor'
33
+
34
+ // `extern crate foo;` and the leading segment of any `foo::path` use/expr path.
35
+ // The leading segment of a path that ISN'T crate/self/super or a std-family
36
+ // root is a candidate external crate name; resolveVendoredCrate keeps only the
37
+ // ones that actually exist under vendor/. The lead must start lowercase to skip
38
+ // `Type::assoc` associated-item paths (crate names are snake_case by convention).
39
+ const EXTERN_CRATE_RE = /^(?:pub\s+)?extern\s+crate\s+(\w+)\s*;/u
40
+ const PATH_LEAD_RE = /\b([a-z_]\w*)(?:::\w+)+/gu
41
+ const NON_CRATE_LEADS = new Set(['crate', 'self', 'super', 'std', 'core', 'alloc'])
42
+
43
+ // A `#[cfg(...)]` / `#[cfg_attr(...)]` attribute gates the next item on a build
44
+ // configuration that may be off (e.g. `#[cfg(test)]`).
45
+ const CFG_ATTR_RE = /^#\[\s*cfg(?:_attr)?\b/u
46
+
47
+ // Strip `//` line comments and `/* */` block comments so a commented-out
48
+ // `mod`/`use` isn't mistaken for a real one — a commented `mod foo;` with no
49
+ // backing file would otherwise be reported as a fatal "missing module".
50
+ // Newlines inside block comments are preserved so the per-line matching below
51
+ // stays aligned. Not string-literal aware (a `//` or `/* */` inside a string
52
+ // can't masquerade as a line-anchored `mod` decl, so it doesn't matter for
53
+ // extraction), and nested block comments aren't handled — both are acceptable
54
+ // for best-effort extraction and match the Solidity loader's fidelity.
55
+ function stripRustComments(content) {
56
+ const noBlock = content.replace(/\/\*[\s\S]*?\*\//gu, (m) => m.replace(/[^\n]/gu, ' '))
57
+ return noBlock.replace(/\/\/[^\n]*/gu, '')
58
+ }
59
+
60
+ // Extract external `mod <name>;` declarations as { name, conditional } records.
61
+ // `conditional` is true when a `#[cfg(...)]` attribute gates the declaration:
62
+ // such a module may be compiled out (e.g. `#[cfg(test)] mod tests;`), so the
63
+ // bundle builder treats its absence as tolerable rather than fatal.
64
+ export function extractModDecls(content) {
65
+ const decls = []
66
+ let cfgPending = false
67
+ for (const raw of stripRustComments(content).split('\n')) {
68
+ const line = raw.trim()
69
+ if (line === '') continue // blank lines don't break a contiguous attribute group
70
+ const m = MOD_DECL_RE.exec(line)
71
+ if (m) {
72
+ decls.push({ name: m[1], conditional: cfgPending })
73
+ cfgPending = false
74
+ } else if (CFG_ATTR_RE.test(line)) {
75
+ cfgPending = true
76
+ } else if (!line.startsWith('#[')) {
77
+ cfgPending = false // a non-attribute line ends the attribute group
78
+ }
79
+ }
80
+ return decls
81
+ }
82
+
83
+ export function extractCrateUses(content) {
84
+ return [...stripRustComments(content).matchAll(USE_CRATE_RE)].map((m) => m[0])
85
+ }
86
+
87
+ // Candidate external-crate names referenced by a file: `extern crate <name>;`
88
+ // plus the leading segment of every `<name>::...` path (minus crate/self/super
89
+ // and the std family). Best-effort and deliberately over-inclusive — a lead
90
+ // that doesn't name a real vendored crate is dropped by resolveVendoredCrate.
91
+ export function extractExternalCrates(content) {
92
+ const stripped = stripRustComments(content)
93
+ const names = new Set()
94
+ for (const raw of stripped.split('\n')) {
95
+ const m = EXTERN_CRATE_RE.exec(raw.trim())
96
+ if (m) names.add(m[1])
97
+ }
98
+ for (const m of stripped.matchAll(PATH_LEAD_RE)) {
99
+ if (!NON_CRATE_LEADS.has(m[1])) names.add(m[1])
100
+ }
101
+ return [...names]
102
+ }
103
+
104
+ // Resolve an external crate name to its vendored crate-root file
105
+ // (`vendor/<dir>/src/lib.rs`), or null when no such crate is vendored. A Rust
106
+ // `use` name is the crate's lib name (underscores); the on-disk vendor dir uses
107
+ // the package name, which may hyphenate — so try the name as-is and with
108
+ // underscores swapped for hyphens. Existence is checked against `knownSources`
109
+ // (tree mode) or the filesystem under `baseDir` (walk mode).
110
+ export function resolveVendoredCrate(crateName, { knownSources, baseDir, vendorDir = VENDOR_DIR } = {}) {
111
+ const dirs = crateName.includes('_') ? [crateName, crateName.replaceAll('_', '-')] : [crateName]
112
+ for (const dir of dirs) {
113
+ const lib = `${vendorDir}/${dir}/src/lib.rs`
114
+ if (knownSources) {
115
+ if (knownSources.has(lib)) return lib
116
+ } else if (baseDir) {
117
+ try {
118
+ if (statSync(join(baseDir, lib)).isFile()) return lib
119
+ } catch { /* not this spelling — try the next */ }
120
+ }
121
+ }
122
+ return null
123
+ }
124
+
125
+ // The directory where this file's submodules live, per Rust's path rules:
126
+ // - crate roots (main.rs/lib.rs) and mod.rs hold submodules as siblings
127
+ // - any other foo.rs holds them under a foo/ subdirectory
128
+ export function getModuleDir(filePath) {
129
+ const lastSlash = filePath.lastIndexOf('/')
130
+ const dir = lastSlash === -1 ? '' : filePath.slice(0, lastSlash)
131
+ const name = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1)
132
+ if (name === 'main.rs' || name === 'lib.rs' || name === 'mod.rs') return dir
133
+ const stem = name.replace(/\.rs$/u, '')
134
+ return dir ? `${dir}/${stem}` : stem
135
+ }
136
+
137
+ // Resolve a `mod <name>;` declaration to the baseDir-relative file that
138
+ // defines it, trying `<dir>/<name>.rs` then `<dir>/<name>/mod.rs`. Existence
139
+ // is checked against `knownSources` (tree mode) or the filesystem under
140
+ // `baseDir` (walk mode); returns null when neither finds a file.
141
+ export function resolveModPath(modName, fromFile, { knownSources, baseDir } = {}) {
142
+ const modDir = getModuleDir(fromFile)
143
+ const base = modDir ? `${modDir}/${modName}` : modName
144
+ const candidates = [`${base}.rs`, `${base}/mod.rs`]
145
+
146
+ if (knownSources) return candidates.find((c) => knownSources.has(c)) ?? null
147
+ if (baseDir) {
148
+ for (const c of candidates) {
149
+ try {
150
+ if (statSync(join(baseDir, c)).isFile()) return c
151
+ } catch { /* missing — try the next candidate */ }
152
+ }
153
+ }
154
+ return null
155
+ }
156
+
157
+ // Build a `module path -> file` map (e.g. "crate::foo::bar" -> "src/foo/bar.rs")
158
+ // by walking `mod` edges out from every crate root (main.rs / lib.rs). The walk
159
+ // is iterative (a deep `mod` chain would overflow a recursive descent) and
160
+ // depth-bounded: a `seen` set stops mod cycles, and MAX_MODULE_DEPTH caps a
161
+ // pathologically deep chain — both the descent and the O(depth²) growth of the
162
+ // joined module-path strings. Real crates nest a handful deep; the cap only
163
+ // ever trims best-effort `use crate::` resolution for absurd input (file
164
+ // collection happens in collectRustFilesFromDisk, which is unaffected).
165
+ const MAX_MODULE_DEPTH = 1000
166
+
167
+ export function buildModuleTree(sources, resolutions, { vendorDir = null } = {}) {
168
+ const tree = new Map()
169
+ for (const filePath of sources.keys()) {
170
+ // A vendored crate is its own crate, not part of the project crate rooted
171
+ // at `crate::` — skip its root so its tree doesn't collide on the `crate`
172
+ // key (its internal `crate::` edges are left best-effort/unresolved).
173
+ if (vendorDir && filePath.startsWith(`${vendorDir}/`)) continue
174
+ const name = filePath.includes('/') ? filePath.slice(filePath.lastIndexOf('/') + 1) : filePath
175
+ if (name !== 'main.rs' && name !== 'lib.rs') continue
176
+ const seen = new Set()
177
+ const stack = [[filePath, 'crate', 0]]
178
+ while (stack.length > 0) {
179
+ const [path, modulePath, depth] = stack.pop()
180
+ tree.set(modulePath, path)
181
+ if (seen.has(path) || depth >= MAX_MODULE_DEPTH) continue
182
+ seen.add(path)
183
+ const specMap = resolutions.get(path)
184
+ if (!specMap) continue
185
+ for (const [spec, resolved] of specMap) {
186
+ if (!spec.startsWith('mod ')) continue
187
+ stack.push([resolved, `${modulePath}::${spec.slice(4)}`, depth + 1])
188
+ }
189
+ }
190
+ }
191
+ return tree
192
+ }
193
+
194
+ // Resolve a `crate::a::b::Item` path to the file of the deepest module along
195
+ // it (the trailing item names aren't modules), e.g. `crate::foo::Bar` ->
196
+ // the file for `crate::foo`. Returns null when no prefix names a module.
197
+ export function resolveUsePath(usePath, moduleTree) {
198
+ const parts = usePath.split('::')
199
+ for (let i = parts.length; i >= 1; i--) {
200
+ const modulePath = parts.slice(0, i).join('::')
201
+ if (moduleTree.has(modulePath)) return moduleTree.get(modulePath)
202
+ }
203
+ return null
204
+ }
205
+
206
+ // Build the { sources, resolutions, missing } triple from a Map of
207
+ // already-loaded Rust sources. Two-phase: resolve `mod` edges (which also
208
+ // defines the module tree), then resolve `crate::` references against it.
209
+ // An unconditional `mod foo;` with no file is recorded in `missing` (the
210
+ // bundle builder treats it as fatal); a cfg-gated `mod`, unresolved `crate::`
211
+ // references, and self/duplicate references are omitted.
212
+ export function buildRustTree(sources) {
213
+ const resolutions = new Map()
214
+ const missing = []
215
+ for (const [path, content] of sources) {
216
+ const specMap = new Map()
217
+ for (const { name, conditional } of extractModDecls(content)) {
218
+ const resolved = resolveModPath(name, path, { knownSources: sources })
219
+ if (resolved) {
220
+ specMap.set(`mod ${name}`, resolved)
221
+ } else if (!conditional) {
222
+ console.warn(`[loader.rust] Missing module: ${name} from ${path}`)
223
+ missing.push({ spec: `mod ${name}`, from: path })
224
+ }
225
+ // conditional && unresolved: a cfg-gated module that may be compiled out
226
+ // (e.g. #[cfg(test)] mod tests;) — tolerated, not a fatal hole.
227
+ }
228
+ resolutions.set(path, specMap)
229
+ }
230
+
231
+ const moduleTree = buildModuleTree(sources, resolutions, { vendorDir: VENDOR_DIR })
232
+ for (const [path, content] of sources) {
233
+ // Vendored crates keep their (file-relative) `mod` edges from above, but
234
+ // their `crate::`/external `use` edges are dropped rather than mis-resolved
235
+ // against the project crate's module tree.
236
+ if (path.startsWith(`${VENDOR_DIR}/`)) continue
237
+ const specMap = resolutions.get(path)
238
+ for (const usePath of extractCrateUses(content)) {
239
+ const resolved = resolveUsePath(usePath, moduleTree)
240
+ if (resolved && resolved !== path && !specMap.has(usePath)) {
241
+ specMap.set(usePath, resolved)
242
+ }
243
+ }
244
+ // A `use <crate>::…` of a vendored crate becomes a `use <crate>` edge to
245
+ // that crate's root, recording the cross-crate dependency in the graph.
246
+ for (const crate of extractExternalCrates(content)) {
247
+ const lib = resolveVendoredCrate(crate, { knownSources: sources })
248
+ const spec = `use ${crate}`
249
+ if (lib && lib !== path && !specMap.has(spec)) specMap.set(spec, lib)
250
+ }
251
+ }
252
+ return { sources, resolutions, missing }
253
+ }
254
+
255
+ // Walk the filesystem from `entries`, following `mod` declarations and reading
256
+ // each reachable file once. A `mod` whose file is missing on disk warns and is
257
+ // skipped; the walk continues. Files in a wave are read in parallel.
258
+ export async function collectRustFilesFromDisk(baseDir, entries) {
259
+ const sources = new Map()
260
+ const realBase = realpathSync(baseDir)
261
+
262
+ const processWave = async (wave) => {
263
+ const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
264
+ if (toLoad.length === 0) return
265
+ const reads = await Promise.all(
266
+ toLoad.map(async (relPath) => {
267
+ try {
268
+ assertRealPathWithinBase(realBase, baseDir, relPath)
269
+ return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
270
+ } catch (err) {
271
+ if (err.code === 'ENOENT') {
272
+ console.warn(`[loader.rust] Missing file: ${relPath}`)
273
+ return null
274
+ }
275
+ // resolveModPath gates candidates with isFile so a directory is
276
+ // normally never queued; guard EISDIR anyway (symmetry with bash).
277
+ if (err.code === 'EISDIR') {
278
+ console.warn(`[loader.rust] Skipping directory reference: ${relPath}`)
279
+ return null
280
+ }
281
+ throw err
282
+ }
283
+ })
284
+ )
285
+ const next = []
286
+ for (const entry of reads) {
287
+ if (!entry) continue
288
+ const [relPath, content] = entry
289
+ sources.set(relPath, content)
290
+ for (const { name } of extractModDecls(content)) {
291
+ const resolved = resolveModPath(name, relPath, { baseDir })
292
+ if (resolved && !sources.has(resolved)) next.push(resolved)
293
+ }
294
+ // Follow `use <crate>::…` into a vendored crate's root; its own `mod`
295
+ // edges (read on the next wave) then pull in the rest of the crate.
296
+ for (const crate of extractExternalCrates(content)) {
297
+ const lib = resolveVendoredCrate(crate, { baseDir })
298
+ if (lib && !sources.has(lib)) next.push(lib)
299
+ }
300
+ }
301
+ await processWave(next)
302
+ }
303
+
304
+ await processWave(entries)
305
+ return sources
306
+ }
307
+
308
+ // Reject absolute paths and `..`-escaping paths in a `.rs.txt` listing so a
309
+ // malicious or sloppy listing can't read files outside the listing's own dir.
310
+ function assertWithinBase(baseDir, candidate, label) {
311
+ if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
312
+ const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
313
+ if (rel.startsWith('..') || isAbsolute(rel)) {
314
+ throw new Error(`${label} escapes baseDir: ${candidate}`)
315
+ }
316
+ }
317
+
318
+ // High-level: takes a `.rs.txt` listing whose lines are crate entry points
319
+ // (typically src/main.rs or src/lib.rs), resolved relative to the listing.
320
+ // Walks `mod` declarations from there, then resolves `crate::` references.
321
+ // Returns { sources, resolutions, missing }.
322
+ export async function loadRust(rsTxtFile) {
323
+ const baseDir = dirname(resolve(rsTxtFile))
324
+ const listing = await readFile(rsTxtFile, 'utf8')
325
+ const lines = listing.split('\n').map((l) => l.trim()).filter(Boolean)
326
+ .map((l) => l.replace(/^\.\//u, ''))
327
+ if (lines.length === 0) throw new Error(`Empty Rust listing: ${rsTxtFile}`)
328
+
329
+ if (!lines.every((line) => extname(line) === '.rs')) {
330
+ throw new Error(`Rust listing must only contain .rs files: ${rsTxtFile}`)
331
+ }
332
+ for (const line of lines) assertWithinBase(baseDir, line, 'Entry path')
333
+
334
+ const sources = await collectRustFilesFromDisk(baseDir, lines)
335
+ return buildRustTree(sources)
336
+ }
@@ -5,10 +5,13 @@
5
5
  // file (foundry.toml or remappings.txt) is read to extract `remappings`,
6
6
  // but is itself not included in `sources`.
7
7
 
8
+ import { realpathSync, statSync } from 'node:fs'
8
9
  import { readFile } from 'node:fs/promises'
9
10
  import { createRequire } from 'node:module'
10
11
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
11
12
 
13
+ import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
14
+
12
15
  const SOL_IMPORT_RE = /import\s[^"']*["']([^"']+)["']/gu
13
16
 
14
17
  export function extractSolImports(content) {
@@ -30,13 +33,18 @@ export function parseRemappings(content) {
30
33
  }
31
34
 
32
35
  // Resolve a Solidity import specifier to a baseDir-relative POSIX path,
33
- // trying three strategies in order:
36
+ // trying four strategies in order:
34
37
  // 1. user remappings (longest prefix wins)
35
38
  // 2. relative path (`./...` or `../...`); traversal above the project
36
39
  // root returns null instead of clamping
37
40
  // 3. Node's CJS resolver via createRequire, anchored at the importing
38
41
  // source — only when `baseDir` is provided and the specifier looks
39
42
  // like a scoped npm package (`@scope/pkg/sub/path`)
43
+ // 4. project-relative fallback: when `baseDir` is provided and a file
44
+ // exists at `<baseDir>/<specifier>`, accept the specifier as-is.
45
+ // Equivalent to an implicit `<top>/=<top>/` remapping for any
46
+ // top-level directory in the project. Lets Foundry-style
47
+ // `import "src/Foo.sol"` work without an explicit remapping.
40
48
  // Returns null when no strategy resolves the import.
41
49
  export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir } = {}) {
42
50
  let best = null
@@ -84,6 +92,21 @@ export function resolveSolImport(specifier, fromFile, { remappings = [], baseDir
84
92
  } catch { /* package missing, exports map blocks the subpath, … */ }
85
93
  }
86
94
 
95
+ // Project-relative fallback: when nothing else resolved, accept the
96
+ // spec as-is if a real file sits at `<baseDir>/<spec>`. statSync's
97
+ // isFile() guard keeps directories and other non-files from being
98
+ // queued for readFile (which would surface as EISDIR mid-walk). `./`
99
+ // and `../` specs are already routed through the relative branch
100
+ // above, so we only see bare specs here.
101
+ if (baseDir && !isAbsolute(specifier)) {
102
+ const rel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
103
+ if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)) {
104
+ try {
105
+ if (statSync(join(baseDir, rel)).isFile()) return rel
106
+ } catch { /* missing or not statable */ }
107
+ }
108
+ }
109
+
87
110
  return null
88
111
  }
89
112
 
@@ -124,6 +147,7 @@ export function buildSolidityTree(sources, { remappings = [], baseDir } = {}) {
124
147
  export async function collectSolidityFilesFromDisk(baseDir, entries, remappings) {
125
148
  const sources = new Map()
126
149
  const knownEntries = new Set(entries)
150
+ const realBase = realpathSync(baseDir)
127
151
 
128
152
  const processWave = async (wave) => {
129
153
  const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
@@ -131,6 +155,7 @@ export async function collectSolidityFilesFromDisk(baseDir, entries, remappings)
131
155
  const reads = await Promise.all(
132
156
  toLoad.map(async (relPath) => {
133
157
  try {
158
+ assertRealPathWithinBase(realBase, baseDir, relPath)
134
159
  return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
135
160
  } catch (err) {
136
161
  if (err.code === 'ENOENT') {
package/src/lockfile.js CHANGED
@@ -1,80 +1,4 @@
1
- import { assert, fileSetToObject, fromEntries, sortPaths } from './util.js'
2
-
3
- const VERSION = 0
4
-
5
- const normalize = ({ name, version, files }) =>
6
- ({ name, version, files: fromEntries(Object.entries(files)) })
7
-
8
- export class Lockfile {
9
- static VERSION = VERSION
10
-
11
- version = VERSION
12
- config
13
- entries
14
- modules
15
-
16
- constructor({ config = { scope: 'full' }, entries, modules } = {}) {
17
- assert(['node_modules', 'full'].includes(config.scope))
18
- this.config = config
19
- this.entries = entries ?? new Set()
20
- this.modules = modules ?? new Map()
21
- }
22
-
23
- static parse(text) {
24
- const json = JSON.parse(text)
25
- assert(json.version === VERSION)
26
- assert(['node_modules', 'full'].includes(json.config?.scope))
27
-
28
- const full = json.config.scope === 'full'
29
- assert(!!json.entries === full)
30
- assert(!!json.sources === full)
31
- assert(json.modules)
32
-
33
- const modules = new Map()
34
- for (const [dir, info] of Object.entries(json.modules)) {
35
- assert(dir.includes('node_modules'))
36
- assert(info?.name && info.version && info.files)
37
- modules.set(dir, normalize(info))
38
- }
39
-
40
- let entries = new Set()
41
- if (full) {
42
- assert(Array.isArray(json.entries))
43
- entries = new Set(json.entries)
44
- for (const [dir, info] of Object.entries(json.sources)) {
45
- assert(!dir.includes('node_modules'))
46
- modules.set(dir, normalize(info))
47
- }
48
- }
49
-
50
- for (const [dir, { files }] of modules) {
51
- assert(!dir.startsWith('..'))
52
- assert(files)
53
- for (const name of Object.keys(files)) {
54
- assert(!name.startsWith('..'))
55
- }
56
- }
57
-
58
- return new Lockfile({ config: json.config, entries, modules })
59
- }
60
-
61
- serialize() {
62
- const entries = fileSetToObject(this.entries)
63
- const modules = []
64
- const sources = []
65
- for (const [dir, { name, version, files }] of this.modules) {
66
- const inNodeModules = dir.includes('node_modules')
67
- if (inNodeModules) assert(name && version && files)
68
- const type = inNodeModules ? modules : sources
69
- const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
70
- type.push([dir, { name, version, files: sorted }])
71
- }
72
- modules.sort((a, b) => sortPaths(a[0], b[0]))
73
- sources.sort((a, b) => sortPaths(a[0], b[0]))
74
-
75
- const store = { version: this.version, config: this.config }
76
- if (this.config.scope === 'full') Object.assign(store, { entries, sources: fromEntries(sources) })
77
- Object.assign(store, { modules: fromEntries(modules) })
78
- return JSON.stringify(store, undefined, 2) + '\n'
79
- }
80
- }
1
+ // Public-export adapter: backs `@exodus/stasis/lockfile`. The implementation lives in
2
+ // @exodus/stasis-core; internal tooling and tests import `@exodus/stasis-core/lockfile`
3
+ // directly, so this file exists only to back the package's `exports` map.
4
+ export * from '@exodus/stasis-core/lockfile'
@@ -0,0 +1,6 @@
1
+ // Public-export adapter: backs `@exodus/stasis/metro-transformer`. The transformer
2
+ // lives in @exodus/stasis-plugins (which depends on @exodus/stasis-core for State; it
3
+ // drives load-mode getFile directly). Wire it as Metro's `transformer.transformerPath`
4
+ // in load mode; see the source for the load asymmetry that lets this run per-worker
5
+ // without cross-worker state sync.
6
+ export * from '@exodus/stasis-plugins/metro-transformer'
package/src/metro.js ADDED
@@ -0,0 +1,5 @@
1
+ // Public-export adapter: backs `@exodus/stasis/metro`. The plugin lives in
2
+ // @exodus/stasis-plugins (which depends on @exodus/stasis-core for State /
3
+ // resolvePluginState). Internal code uses `@exodus/stasis-plugins/metro` directly;
4
+ // this file exists only to back the package's `exports` map.
5
+ export * from '@exodus/stasis-plugins/metro'