@exodus/stasis 1.0.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Exodus Movement
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # `@exodus/stasis`
2
+
3
+ Analyzeable source code bundles with resolutions + per-file lockfiles
4
+
5
+ ## Bundles
6
+
7
+ ## Lockfiles
8
+
9
+ ## License
10
+
11
+ [MIT](./LICENSE)
package/bin/stasis.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { parseArgs } from 'node:util'
5
+ import { once } from 'node:events'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { basename, resolve } from 'node:path'
8
+ import { existsSync, realpathSync } from 'node:fs'
9
+ import assert from 'node:assert/strict'
10
+ import pkg from '../package.json' with { type: 'json' }
11
+
12
+ const argv = [...process.argv]
13
+ assert(['node', 'node.exe'].includes(basename(argv.shift())))
14
+ assert(['node', 'node.exe'].includes(basename(process.argv0)))
15
+
16
+ const jsname = argv.shift()
17
+ const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
18
+ assert(basename(jsname) === 'stasis' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
19
+
20
+ function usage(prefix = '') {
21
+ console.error(`${prefix}\nUsage:
22
+ stasis run --lock=(add|replace|frozen|ignore) [--bundle=(add|replace|load|ignore)] [--bundle-file=path/to/bundle.br] [--full] path/to/file.js ...
23
+ stasis bundle create path/to/lockfile
24
+ stasis bundle verify path/to/lockfile
25
+ stasis prune [path/to/project]
26
+ stasis audit path/to/file ...
27
+ `.trim())
28
+ process.exit(1)
29
+ }
30
+
31
+ function setEnv(name, value) {
32
+ const env = process.env[name]
33
+ if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
34
+ process.env[name] = value === undefined ? '' : value
35
+ }
36
+
37
+ const command = argv.shift()
38
+
39
+ if (command === '-v' || command === '--version') {
40
+ console.log(`v${pkg.version}`)
41
+ process.exit(0)
42
+ } else if (command === 'run') {
43
+ const flags = []
44
+ const valueFlags = new Set(['--bundle', '--bundle-file', '--lock'])
45
+ while (argv.length > 0 && (argv[0].startsWith('-') || valueFlags.has(flags.at(-1)))) {
46
+ flags.push(argv.shift())
47
+ }
48
+
49
+ const options = {
50
+ lock: { type: 'string', default: 'none' },
51
+ bundle: { type: 'string', default: 'none' },
52
+ 'bundle-file': { type: 'string' },
53
+ debug: { type: 'boolean' },
54
+ full: { type: 'boolean' },
55
+ }
56
+
57
+ const { values } = parseArgs({ args: flags, options })
58
+ if (argv.length === 0) usage('Nothing to run: no path to file given')
59
+ if (!['none', 'ignore', 'add', 'replace', 'frozen'].includes(values.lock)) usage('Error: invalid --lock value')
60
+ const lock = values.lock
61
+ const scope = values.full ? 'full' : 'node_modules'
62
+ const bundle = values.bundle
63
+ const bundleFile = values['bundle-file'] ? resolve(values['bundle-file']) : ''
64
+ const debug = values.debug ? '1' : ''
65
+ if (!['none', 'ignore', 'add', 'replace', 'load'].includes(bundle)) usage('Error: invalid --bundle value')
66
+ if (bundleFile && bundle === 'none') usage('Error: --bundle-file requires --bundle=(add|replace|load|ignore)')
67
+ if (bundle === 'load' && lock !== 'frozen' && lock !== 'none' && lock !== 'ignore') usage('Error: --bundle=load requires --lock=(frozen|none|ignore)')
68
+ if (lock === 'none' && bundle === 'none') usage('Error: --lock=none requires --bundle=(add|replace|load|ignore)')
69
+ console.warn('[stasis] Running stasis with config:', { lock, scope, bundle, ...(bundleFile && { bundleFile }) })
70
+ if (debug) console.warn(`[stasis] Warning: stasis debug mode active`)
71
+ setEnv('EXODUS_STASIS_LOCK', lock)
72
+ setEnv('EXODUS_STASIS_SCOPE', scope)
73
+ setEnv('EXODUS_STASIS_BUNDLE', bundle)
74
+ setEnv('EXODUS_STASIS_BUNDLE_FILE', bundleFile)
75
+ setEnv('EXODUS_STASIS_DEBUG', debug)
76
+ const child = spawn('node', ['--import', import.meta.resolve('../src/loader.js'), ...argv], { stdio: 'inherit' })
77
+ const [code] = await once(child, 'close')
78
+ process.exitCode = code
79
+ } else if (command === 'bundle') {
80
+ usage('bundle command is not implemented yet')
81
+ } else if (command === 'prune') {
82
+ if (argv.length > 1) usage('Error: prune takes at most one path argument')
83
+ const root = argv[0] ? resolve(argv[0]) : process.cwd()
84
+ const { prune } = await import('../src/prune.js')
85
+ const { removed, validated } = prune({ root })
86
+ console.warn(`[stasis] prune: validated ${validated.length} file(s), removed ${removed.length} file(s)`)
87
+ } else if (command === 'audit') {
88
+ if (argv.length === 0) usage('Nothing to audit: no path to file given')
89
+ const { audit, printAuditReport } = await import('../src/audit.js')
90
+ const files = argv.map((f) => resolve(f))
91
+ const report = await audit(files)
92
+ printAuditReport(report)
93
+ process.exitCode = report.rows.length === 0 ? 0 : 1
94
+ } else {
95
+ usage()
96
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@exodus/stasis",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "A research project",
5
+ "type": "module",
6
+ "exports": {
7
+ "./esbuild": "./src/esbuild.js",
8
+ "./webpack": "./src/webpack.js",
9
+ "./loader": "./src/loader.js",
10
+ "./bundle": "./src/bundle.js",
11
+ "./lockfile": "./src/lockfile.js",
12
+ "./prune": "./src/prune.js"
13
+ },
14
+ "files": [
15
+ "bin/stasis.js",
16
+ "src/apis/npm/index.js",
17
+ "src/apis/npm/semver.cjs",
18
+ "src/bundle.js",
19
+ "src/config.js",
20
+ "src/esbuild.js",
21
+ "src/loader.js",
22
+ "src/lockfile.js",
23
+ "src/prune.js",
24
+ "src/state.js",
25
+ "src/state.util.js",
26
+ "src/util.js",
27
+ "src/webpack.js"
28
+ ],
29
+ "engines": {
30
+ "node": ">=24.14.0"
31
+ },
32
+ "bin": {
33
+ "stasis": "bin/stasis.js"
34
+ },
35
+ "scripts": {
36
+ "lint": "oxlint",
37
+ "test": "node --run test:coherence",
38
+ "test:coherence": "node --test"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/ExodusMovement/stasis.git"
43
+ },
44
+ "author": "Exodus Movement, Inc.",
45
+ "license": "MIT",
46
+ "bugs": {
47
+ "url": "https://github.com/ExodusMovement/stasis/issues"
48
+ },
49
+ "homepage": "https://github.com/ExodusMovement/stasis#readme",
50
+ "devDependencies": {
51
+ "@exodus/bytes": "^1.15.0",
52
+ "esbuild": "^0.27.3",
53
+ "oxlint": "^1.52.0",
54
+ "string-replace-loader": "^2.3.0",
55
+ "webpack": "^4.47.0"
56
+ },
57
+ "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
58
+ }
@@ -0,0 +1,36 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ import semver from './semver.cjs'
4
+
5
+ const packageNameRegex = /^(@[\da-z-]+\/)?[\w-]+(\.[\w-]+)*$/u
6
+
7
+ export async function advisories(list, { signal = AbortSignal.timeout(30_000) } = {}) {
8
+ const groups = new Map()
9
+ for (const { name, version } of list) {
10
+ assert(typeof name === 'string' && typeof version === 'string')
11
+ assert(packageNameRegex.test(name), `Unexpected package name: ${name}`)
12
+ assert(semver.valid(version), `Invalid version: ${version}`)
13
+ if (!groups.has(name)) groups.set(name, new Set())
14
+ groups.get(name).add(version)
15
+ }
16
+
17
+ const entries = [...groups].map(([k, v]) => [k, [...v].toSorted((a, b) => semver.compare(a, b))])
18
+ const body = Object.fromEntries(entries.toSorted((a, b) => a[0] < b[0] ? -1 : 1))
19
+
20
+ let res
21
+ try {
22
+ res = await fetch('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify(body),
26
+ signal,
27
+ })
28
+ } catch (cause) {
29
+ throw new Error(`npm advisories request failed: ${cause.message}`, { cause })
30
+ }
31
+ if (!res.ok) {
32
+ const text = await res.text().catch(() => '')
33
+ throw new Error(`npm advisories request failed: ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 200)}` : ''}`)
34
+ }
35
+ return res.json()
36
+ }
@@ -0,0 +1,44 @@
1
+ 'use strict'
2
+
3
+ const assert = require('node:assert/strict')
4
+ const { basename, dirname, resolve } = require('node:path')
5
+
6
+ let semver
7
+
8
+ const argv0 = process.argv[0] // not process.argv0 which lacks path
9
+
10
+ function getSemver() {
11
+ if (semver) return semver
12
+
13
+ // Coherence check that we got Node.js path there
14
+ assert(['node', 'node.exe'].includes(basename(argv0)))
15
+ assert.equal(basename(argv0), basename(process.argv0))
16
+
17
+ // Load semver from npm bundled with Node.js
18
+ const semverDir = resolve(dirname(argv0), '../lib/node_modules/npm/node_modules/semver')
19
+ semver = require(semverDir)
20
+ return semver
21
+ }
22
+
23
+ exports.compare = (...a) => getSemver().compare(...a)
24
+ exports.gtr = (...a) => getSemver().gtr(...a)
25
+ exports.lt = (...a) => getSemver().lt(...a)
26
+ exports.lte = (...a) => getSemver().lte(...a)
27
+ exports.ltr = (...a) => getSemver().ltr(...a)
28
+ exports.satisfies = (...a) => getSemver().satisfies(...a)
29
+ exports.validRange = (...a) => getSemver().validRange(...a)
30
+
31
+ exports.valid = (version, options) => {
32
+ // Fast path for certainly valid versions
33
+ if (
34
+ options === undefined &&
35
+ typeof version === 'string' &&
36
+ version.length <= 256 &&
37
+ // 1-15 chars because it must fit in Number.MAX_SAFE_INTEGER
38
+ /^(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})\.(0|[1-9]\d{0,14})$/iu.test(version)
39
+ ) {
40
+ return version
41
+ }
42
+
43
+ return getSemver().valid(version, options)
44
+ }
package/src/bundle.js ADDED
@@ -0,0 +1,217 @@
1
+ import {
2
+ assert,
3
+ fileMapToObject,
4
+ fileSetToObject,
5
+ fromEntries,
6
+ objectToMaps,
7
+ sortPaths,
8
+ splitNodeModulesPath,
9
+ } from './util.js'
10
+
11
+ const VERSION = 1
12
+ const LEGACY_VERSION = 0
13
+
14
+ const normalize = ({ name, version, files }) =>
15
+ ({ name, version, files: fromEntries(Object.entries(files)) })
16
+
17
+ const inferModuleDir = (path) =>
18
+ splitNodeModulesPath(path) ?? { dir: '.', rel: path, name: null }
19
+
20
+ // Bundle handles the JSON shape of stasis.code.br / stasis.resources.br;
21
+ // callers (State) are responsible for the brotli wrapper around the JSON
22
+ // text. On-disk version:
23
+ // v0 — legacy flat layout: top-level `sources` (or `resources`) keyed by
24
+ // project-relative path. No entries/modules metadata.
25
+ // v1 — current layout, mirrors stasis.lock.json: per-package buckets under
26
+ // `sources` (workspace) and `modules` (node_modules) with name+version+files,
27
+ // plus `entries`. File payloads carry bytes (UTF-8 source strings or base64
28
+ // resource blobs) instead of SRI digests.
29
+ // We load both v0 and v1; we always serialize v1. v0 is converted on load to
30
+ // the same in-memory shape — paths are regrouped by package dir via
31
+ // `inferModuleDir` (best-effort, last-wins). name/version stay null since v0
32
+ // records neither, which State uses as a marker to skip metadata cross-checks.
33
+ export class Bundle {
34
+ static VERSION = VERSION
35
+
36
+ // The on-disk version this instance represents. Defaults to VERSION; parse*
37
+ // sets it to whatever the file declared (LEGACY_VERSION for v0 input).
38
+ // Serialization always writes the current VERSION regardless — loading v0 and
39
+ // re-saving promotes the bundle to v1.
40
+ version = VERSION
41
+ config
42
+ entries
43
+ modules
44
+ formats
45
+ imports
46
+
47
+ constructor({ config = { scope: 'full' }, entries, modules, formats, imports, version = VERSION } = {}) {
48
+ assert([LEGACY_VERSION, VERSION].includes(version))
49
+ assert(['node_modules', 'full'].includes(config.scope))
50
+ this.version = version
51
+ this.config = config
52
+ this.entries = entries ?? new Set()
53
+ this.modules = modules ?? new Map()
54
+ this.formats = formats ?? new Map()
55
+ this.imports = imports ?? new Map()
56
+ }
57
+
58
+ // Flat project-relative view of file contents collected from this.modules.
59
+ // Values are UTF-8 source strings for code bundles, base64 for resource bundles.
60
+ get sources() {
61
+ const m = new Map()
62
+ for (const [dir, { files }] of this.modules) {
63
+ for (const [rel, content] of Object.entries(files)) {
64
+ m.set(dir === '.' ? rel : `${dir}/${rel}`, content)
65
+ }
66
+ }
67
+ return m
68
+ }
69
+
70
+ static parseCode(text) {
71
+ const json = JSON.parse(text)
72
+ assert(json.version === VERSION || json.version === LEGACY_VERSION)
73
+ assert(['node_modules', 'full'].includes(json.config?.scope))
74
+ assert(json.formats)
75
+ assert(json.imports)
76
+
77
+ const modules = new Map()
78
+ let entries = new Set()
79
+
80
+ if (json.version === VERSION) {
81
+ const full = json.config.scope === 'full'
82
+ if (json.modules !== undefined) {
83
+ assert(typeof json.modules === 'object' && json.modules !== null)
84
+ for (const [dir, info] of Object.entries(json.modules)) {
85
+ assert(dir.includes('node_modules'))
86
+ assert(!dir.startsWith('..'))
87
+ assert(info?.name && info.version && info.files)
88
+ modules.set(dir, normalize(info))
89
+ }
90
+ }
91
+ if (full) {
92
+ assert(Array.isArray(json.entries))
93
+ // Empty entries in v1 + full would let `state.assertEntry` skip its check
94
+ // (it short-circuits on entries.size === 0 for v0+no-lockfile compatibility).
95
+ // Forbid the shape at parse time so tampered bundles can't exploit that path.
96
+ assert(json.entries.length > 0, 'v1 bundle with scope=full must have at least one entry')
97
+ assert(json.sources && typeof json.sources === 'object')
98
+ entries = new Set(json.entries)
99
+ for (const [dir, info] of Object.entries(json.sources)) {
100
+ assert(!dir.includes('node_modules'))
101
+ assert(!dir.startsWith('..'))
102
+ assert(info?.name && info.version && info.files)
103
+ modules.set(dir, normalize(info))
104
+ }
105
+ } else {
106
+ assert(json.entries === undefined)
107
+ assert(json.sources === undefined)
108
+ }
109
+ for (const [, { files }] of modules) {
110
+ for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
111
+ }
112
+ } else {
113
+ // v0 legacy: flat sources at top level, regrouped by inferred module dir.
114
+ assert(json.sources)
115
+ for (const [path, content] of Object.entries(json.sources)) {
116
+ assert(!path.startsWith('..'))
117
+ const { dir, rel, name } = inferModuleDir(path)
118
+ assert(!dir.startsWith('..') && !rel.startsWith('..'))
119
+ if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
120
+ modules.get(dir).files[rel] = content
121
+ }
122
+ }
123
+
124
+ return new Bundle({
125
+ version: json.version,
126
+ config: json.config,
127
+ entries,
128
+ modules,
129
+ formats: new Map(Object.entries(json.formats)),
130
+ imports: objectToMaps(json.imports),
131
+ })
132
+ }
133
+
134
+ static parseResources(text) {
135
+ const json = JSON.parse(text)
136
+ assert(json.version === VERSION || json.version === LEGACY_VERSION)
137
+
138
+ const modules = new Map()
139
+ let config = { scope: 'full' }
140
+
141
+ if (json.version === VERSION) {
142
+ assert(['node_modules', 'full'].includes(json.config?.scope))
143
+ config = json.config
144
+ const full = config.scope === 'full'
145
+ if (json.modules !== undefined) {
146
+ assert(typeof json.modules === 'object' && json.modules !== null)
147
+ for (const [dir, info] of Object.entries(json.modules)) {
148
+ assert(dir.includes('node_modules'))
149
+ assert(!dir.startsWith('..'))
150
+ assert(info?.name && info.version && info.files)
151
+ modules.set(dir, normalize(info))
152
+ }
153
+ }
154
+ if (full && json.sources !== undefined) {
155
+ assert(typeof json.sources === 'object' && json.sources !== null)
156
+ for (const [dir, info] of Object.entries(json.sources)) {
157
+ assert(!dir.includes('node_modules'))
158
+ assert(!dir.startsWith('..'))
159
+ assert(info?.name && info.version && info.files)
160
+ modules.set(dir, normalize(info))
161
+ }
162
+ } else if (!full) {
163
+ assert(json.sources === undefined)
164
+ }
165
+ for (const [, { files }] of modules) {
166
+ for (const rel of Object.keys(files)) assert(!rel.startsWith('..'))
167
+ }
168
+ } else {
169
+ // v0 legacy: flat resources at top level, regrouped by inferred module dir.
170
+ assert(json.resources)
171
+ for (const [path, content] of Object.entries(json.resources)) {
172
+ assert(!path.startsWith('..'))
173
+ const { dir, rel, name } = inferModuleDir(path)
174
+ assert(!dir.startsWith('..') && !rel.startsWith('..'))
175
+ if (!modules.has(dir)) modules.set(dir, { name, version: null, files: Object.create(null) })
176
+ modules.get(dir).files[rel] = content
177
+ }
178
+ }
179
+
180
+ return new Bundle({ version: json.version, config, modules })
181
+ }
182
+
183
+ #groupedFromModules() {
184
+ const moduleEntries = []
185
+ const sourceEntries = []
186
+ for (const [dir, { name, version, files }] of this.modules) {
187
+ if (Object.keys(files).length === 0) continue
188
+ const inNodeModules = dir.includes('node_modules')
189
+ if (inNodeModules) assert(name && version && files)
190
+ const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
191
+ const target = inNodeModules ? moduleEntries : sourceEntries
192
+ target.push([dir, { name, version, files: sorted }])
193
+ }
194
+ moduleEntries.sort((a, b) => sortPaths(a[0], b[0]))
195
+ sourceEntries.sort((a, b) => sortPaths(a[0], b[0]))
196
+ return { modules: fromEntries(moduleEntries), sources: fromEntries(sourceEntries) }
197
+ }
198
+
199
+ serializeCode() {
200
+ const entries = fileSetToObject(this.entries)
201
+ const { modules, sources } = this.#groupedFromModules()
202
+ const formats = fileMapToObject(this.formats)
203
+ const imports = fileMapToObject(this.imports)
204
+ const data = { version: VERSION, config: this.config }
205
+ if (this.config.scope === 'full') Object.assign(data, { entries, sources })
206
+ Object.assign(data, { modules, formats, imports })
207
+ return JSON.stringify(data, undefined, 2)
208
+ }
209
+
210
+ serializeResources() {
211
+ const { modules, sources } = this.#groupedFromModules()
212
+ const data = { version: VERSION, config: this.config }
213
+ if (this.config.scope === 'full') Object.assign(data, { sources })
214
+ Object.assign(data, { modules })
215
+ return JSON.stringify(data, undefined, 2)
216
+ }
217
+ }