@exodus/stasis 1.0.0-beta.0 → 1.0.0-beta.10

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 (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  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 +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -0,0 +1,182 @@
1
+ import { hash } from 'node:crypto'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+
4
+ import { toBase32 } from '@exodus/bytes/base32.js'
5
+ import { isValidRepoField } from '@exodus/stasis-core/bundle'
6
+ import { parseGithubRepository, readJson, readText } from '@exodus/stasis-core/bundle-util'
7
+ import { diskHost } from '@exodus/stasis-core/host'
8
+ import { isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
9
+ import { parsePnpmLockfile } from '@preventive/lockfile/pnpm.js'
10
+ import { parseYarn1Lockfile } from '@preventive/lockfile/yarn1.js'
11
+
12
+ // The commit each git dependency in a node_modules on disk is installed at, as the package manager
13
+ // that laid it out recorded it there: npm in its hidden lockfile, pnpm in its copy of the lockfile,
14
+ // yarn 1 in yarn.lock, as its .yarn-integrity has it installed. Nothing on disk names a registry
15
+ // package's. A record missing, unreadable, or naming no full commit of a GitHub repository records
16
+ // nothing, and fails nothing.
17
+
18
+ // `{ github, commit }` of `repo`, a git URL, at `commit`, where that is a GitHub repository and a full
19
+ // commit id; else undefined.
20
+ function githubCommit(repo, commit) {
21
+ const github = parseGithubRepository(repo)
22
+ return github !== null && isValidRepoField('commit', commit) ? { github, commit } : undefined
23
+ }
24
+
25
+ // GitHub's tarball of a commit, as yarn 1 and pnpm fetch a GitHub dependency.
26
+ const CODELOAD = /^https:\/\/codeload\.github\.com\/([^/]+\/[^/]+)\/tar\.gz\/([^/]+)$/u
27
+ const githubTarball = (url) => {
28
+ const [, github, commit] = CODELOAD.exec(url) ?? []
29
+ return isValidRepoField('github', github) ? githubCommit(github, commit) : undefined
30
+ }
31
+
32
+ // githubCommit of a git or tarball resolution @preventive/lockfile reads.
33
+ const resolutionCommit = (resolution) => {
34
+ if (resolution?.type === 'git') return githubCommit(resolution.repo, resolution.commit)
35
+ return resolution?.type === 'tarball' ? githubTarball(resolution.tarball ?? '') : undefined
36
+ }
37
+
38
+ // The lookup of a record that finds nothing: one unreadable, or none.
39
+ const NONE = () => undefined
40
+
41
+ // npm's, from npm 7: the hidden lockfile it writes into `dir`'s node_modules as it lays it out, by
42
+ // each package's location from `dir`, where it records the version installed there. A git
43
+ // dependency's `resolved` is the URL npm clones, `#` and the commit.
44
+ function npmRecord(dir, host) {
45
+ const packages = readJson(join(dir, 'node_modules', '.package-lock.json'), host)?.packages
46
+ if (!isPlainObject(packages)) return NONE
47
+ return (location, version) => {
48
+ const entry = Object.hasOwn(packages, location) ? packages[location] : undefined
49
+ if (!isPlainObject(entry) || entry.version !== version || typeof entry.resolved !== 'string') return undefined
50
+ const { resolved } = entry
51
+ if (!/^git(?:\+[\w.-]+)?:/u.test(resolved)) return githubTarball(resolved)
52
+ const at = resolved.lastIndexOf('#')
53
+ return at === -1 ? undefined : githubCommit(resolved.slice(0, at), resolved.slice(at + 1))
54
+ }
55
+ }
56
+
57
+ // yarn 1's: yarn.lock in `dir`, by the name a package is installed as, the last of its location, and
58
+ // its version, where one entry alone has both. The .yarn-integrity yarn writes into the node_modules
59
+ // it lays out has to hold the entry's resolution for each of its patterns, as yarn.lock's `resolved`
60
+ // spells it: a yarn.lock changed since often moves a git dependency to another commit of one version.
61
+ function yarnRecord(dir, host) {
62
+ const installed = readJson(join(dir, 'node_modules', '.yarn-integrity'), host)?.lockfileEntries
63
+ const text = readText(host, join(dir, 'yarn.lock'))
64
+ if (!isPlainObject(installed) || text === null) return NONE
65
+ let packages
66
+ try {
67
+ packages = parseYarn1Lockfile(text, { checkVersions: false }).packages
68
+ } catch {
69
+ return NONE // whatever the reader refuses
70
+ }
71
+ const byVersion = new Map()
72
+ for (const { patterns, name, version, resolution } of new Set(Object.values(packages))) {
73
+ let commit = resolutionCommit(resolution)
74
+ const resolved = resolution?.type === 'git' ? `${resolution.repo}#${resolution.commit}` : resolution?.sha1 === undefined ? resolution?.tarball : `${resolution.tarball}#${resolution.sha1}`
75
+ if (!patterns.every((pattern) => Object.hasOwn(installed, pattern) && installed[pattern] === resolved)) commit = undefined
76
+ // Of two entries of one name and version, either may be the one installed.
77
+ const id = `${name}@${version}`
78
+ byVersion.set(id, byVersion.has(id) ? undefined : commit)
79
+ }
80
+ return (location, version) => byVersion.get(`${location.slice(location.lastIndexOf('node_modules/') + 'node_modules/'.length)}@${version}`)
81
+ }
82
+
83
+ // What npm and yarn leave in a node_modules they lay out, and the reader of each one's record.
84
+ const LAYOUTS = [['.package-lock.json', npmRecord], ['.yarn-integrity', yarnRecord]]
85
+
86
+ // The names pnpm 9 to 12 may give snapshot `key`'s directory in node_modules/.pnpm, as its
87
+ // depPathToFilename (@pnpm/dependency-path) spells them: each character a path can't hold made `+`,
88
+ // `#` too from pnpm 10, and a peer's parentheses `_`; where that is too long or has a capital, cut
89
+ // short before `_` and a hash of it, 26 base32 of an MD5 for pnpm 9, 32 hex of a SHA-256 from pnpm 10,
90
+ // of which the hash alone is kept, as where it is cut depends on the install's
91
+ // virtualStoreDirMaxLength.
92
+ const STORE_NAMES = [
93
+ [/[\\/:*?"<>|]/gu, (name) => toBase32(hash('md5', name, 'buffer'), { padding: false }).toLowerCase()],
94
+ [/[\\/:*?"<>|#]/gu, (name) => hash('sha256', name).slice(0, 32)],
95
+ ]
96
+ const storeNames = (key) => STORE_NAMES.flatMap(([unsafe, digest]) => {
97
+ let name = key.replace(unsafe, '+')
98
+ if (name.includes('(')) name = name.replace(/\)$/u, '').replace(/\)\(|\(|\)/gu, '_')
99
+ return [name, `_${digest(name)}`]
100
+ })
101
+ const STORE_HASH = /_(?:[\da-f]{32}|[a-z2-7]{26})$/u
102
+
103
+ // pnpm's: lock.yaml in `store`, a node_modules/.pnpm, the lockfile of what pnpm 9 to 12 installed
104
+ // there, by the directory there each snapshot is in. A git dependency's resolution is the repository
105
+ // and commit, or GitHub's tarball of the commit, and the package's `path` in it (`#path:`) where it is
106
+ // not at the root.
107
+ function pnpmRecord(store, host) {
108
+ const text = readText(host, join(store, 'lock.yaml'))
109
+ let packages
110
+ try {
111
+ packages = text === null ? undefined : parsePnpmLockfile(text).lockfile?.packages
112
+ } catch {
113
+ return NONE // whatever the reader refuses
114
+ }
115
+ const byName = new Map()
116
+ for (const [key, { resolution }] of Object.entries(packages ?? {})) {
117
+ const commit = resolutionCommit(resolution)
118
+ if (commit === undefined) continue
119
+ const directory = (resolution.path ?? '').replace(/^\/+|\/+$/gu, '')
120
+ const repo = { github: commit.github, ...(isValidRepoField('directory', directory) && { directory }), commit: commit.commit }
121
+ for (const name of storeNames(key)) byName.set(name, repo)
122
+ }
123
+ return (name) => byName.get(name) ?? byName.get(STORE_HASH.exec(name)?.[0])
124
+ }
125
+
126
+ // A pnpm package's real path, <store>/<dir>/node_modules/<name>, its store a node_modules/.pnpm.
127
+ const PNPM_PACKAGE = /^(.*\/node_modules\/\.pnpm)\/([^/]+)\/node_modules\/(?:@[^/]+\/)?[^/]+$/u
128
+
129
+ // Each npm dependency of `bundle`, its buckets' paths from `root`, that is a git dependency of a GitHub
130
+ // repository, recorded as its package manager's record has it installed: that repository, over the
131
+ // one its package.json names (a fork's, where that names the one forked), at its root (`''`) or the
132
+ // subdirectory pnpm records, at that commit. A dependency is found by its real path, through the link
133
+ // pnpm installs it by: in a node_modules/.pnpm, by its directory there; else by its location from the
134
+ // nearest directory whose node_modules npm or yarn laid out, from the one its outermost node_modules
135
+ // below the bundle root is in up to that root, whatever a directory above the root is named: a
136
+ // record outside the root is another project's. Metadata, as `repo` is: never checked against the
137
+ // files installed.
138
+ export function pinInstalledCommits(bundle, root, host = diskHost) {
139
+ const stores = new Map()
140
+ const layouts = new Map()
141
+ let top // the bundle root's real path, as each package's is compared with it
142
+ // The record of `dir`'s node_modules, or null where neither npm nor yarn laid it out; where both
143
+ // left theirs, neither's, as which was last is not known.
144
+ const layoutAt = (dir) => {
145
+ if (!layouts.has(dir)) {
146
+ const found = LAYOUTS.filter(([file]) => host.stat(join(dir, 'node_modules', file)) !== null)
147
+ layouts.set(dir, found.length === 0 ? null : found.length === 1 ? found[0][1](dir, host) : NONE)
148
+ }
149
+ return layouts.get(dir)
150
+ }
151
+ for (const [dir, info] of bundle.modules) {
152
+ if (info.ecosystem !== 'npm') continue
153
+ let real
154
+ try {
155
+ real = toPosix(host.realpath(resolve(root, dir)))
156
+ } catch {
157
+ continue
158
+ }
159
+ let repo
160
+ const pnpm = PNPM_PACKAGE.exec(real)
161
+ if (pnpm !== null) {
162
+ const [, store, name] = pnpm
163
+ if (!stores.has(store)) stores.set(store, pnpmRecord(store, host))
164
+ repo = stores.get(store)(name)
165
+ } else {
166
+ try {
167
+ top ??= resolve(host.realpath(resolve(root)))
168
+ } catch {
169
+ return
170
+ }
171
+ const rel = relative(top, real)
172
+ const parts = toPosix(rel).split('/')
173
+ const at = parts.indexOf('node_modules')
174
+ if (relativeEscapes(rel) || at === -1) continue
175
+ let from = resolve(top, ...parts.slice(0, at))
176
+ while (layoutAt(from) === null && from !== top) from = dirname(from)
177
+ const commit = (layoutAt(from) ?? NONE)(toPosix(relative(from, real)), info.version)
178
+ if (commit !== undefined) repo = { github: commit.github, directory: '', commit: commit.commit }
179
+ }
180
+ if (repo !== undefined) bundle.modules.set(dir, { ...info, repo })
181
+ }
182
+ }
@@ -0,0 +1,283 @@
1
+ // Incremental JSON parser over UTF-8 bytes, holding only the value being built and one token in flight.
2
+ // Exactly JSON.parse(Buffer.concat(chunks).toString('utf8')) for any chunking (json-stream.test.js checks it),
3
+ // so a bundle read in a stream can't parse differently from one read whole. Pure: no Node builtins.
4
+ // Not mirrored: Node 24+'s JSON.parse can return a stale escaped key cached by an earlier call (a V8 bug).
5
+
6
+ const QUOTE = 0x22
7
+ const BACKSLASH = 0x5c
8
+
9
+ // What the next significant byte may be.
10
+ const VALUE = 0 // any value: the top level, after ':' or after an array's ','
11
+ const ARRAY_FIRST = 1 // after '[': a value or ']'
12
+ const OBJECT_FIRST = 2 // after '{': a key or '}'
13
+ const KEY = 3 // after an object's ',': a key
14
+ const COLON = 4
15
+ const AFTER = 5 // after a member or element: ',' or the container's closer
16
+ const DONE = 6 // the top-level value is complete: whitespace only
17
+ // Inside a token that can straddle chunks.
18
+ const STRING = 7
19
+ const NUMBER = 8
20
+ const LITERAL = 9
21
+
22
+ const NUMBER_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u
23
+ const LITERALS = new Map([['true', true], ['false', false], ['null', null]])
24
+
25
+ const isWhitespace = (b) => b === 0x20 || b === 0x0a || b === 0x0d || b === 0x09
26
+ // The bytes a number can span; NUMBER_RE checks its grammar once it ends.
27
+ const isNumberByte = (b) => (b >= 0x30 && b <= 0x39) || b === 0x2d || b === 0x2b || b === 0x2e || b === 0x65 || b === 0x45
28
+ const isLetter = (b) => b >= 0x61 && b <= 0x7a
29
+
30
+ const hasControlByte = (bytes) => {
31
+ for (let i = 0; i < bytes.length; i++) if (bytes[i] < 0x20) return true
32
+ return false
33
+ }
34
+
35
+ const indexOrEnd = (bytes, byte, from) => {
36
+ const at = bytes.indexOf(byte, from)
37
+ return at === -1 ? bytes.length : at
38
+ }
39
+
40
+ const concat = (parts) => {
41
+ let length = 0
42
+ for (const part of parts) length += part.length
43
+ const out = new Uint8Array(length)
44
+ let offset = 0
45
+ for (const part of parts) {
46
+ out.set(part, offset)
47
+ offset += part.length
48
+ }
49
+ return out
50
+ }
51
+
52
+ export class JsonStreamParser {
53
+ // ignoreBOM keeps a leading U+FEFF, as Buffer#toString does; no UTF-8 sequence spans a quote, so per-string decoding is exact.
54
+ #decoder = new TextDecoder('utf-8', { ignoreBOM: true })
55
+ #onString
56
+ #state = VALUE
57
+ #stack = [] // open containers, innermost last
58
+ #path = [] // #path[d]: the key or index #stack[d]'s in-flight member lands at
59
+ #result
60
+ #offset = 0 // bytes consumed by earlier writes, for error positions
61
+ #closed = false
62
+ // Cached indexOf results in the current chunk (-1: not searched yet; its length: none left).
63
+ #nextQuote = -1
64
+ #nextBackslash = -1
65
+ #tokenStart = 0
66
+ #isKey = false
67
+ #parts = [] // copied bytes of a string straddling chunks
68
+ #escaped = false
69
+ #pendingEscape = false // a chunk ended on a string's backslash
70
+ #word = '' // a number or literal straddling chunks
71
+
72
+ // onString(value, path) sees each string value (never a key) and its return is stored instead.
73
+ // `path` is the live key/index path: read it, never keep or mutate it.
74
+ constructor({ onString } = {}) {
75
+ this.#onString = onString
76
+ }
77
+
78
+ // A chunk may be reused once write() returns: the bytes of a token left open are copied.
79
+ write(bytes) {
80
+ if (this.#closed) throw new Error('JsonStreamParser: write after end or failure')
81
+ if (!ArrayBuffer.isView(bytes)) throw new TypeError('JsonStreamParser: write() takes a Uint8Array')
82
+ // A plain Uint8Array view: a Buffer's slice() doesn't copy, and its indexOf/subarray are slower.
83
+ const view = bytes.constructor === Uint8Array ? bytes : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
84
+ try {
85
+ this.#consume(view)
86
+ } catch (error) {
87
+ this.#closed = true
88
+ throw error
89
+ }
90
+ this.#offset += view.length
91
+ }
92
+
93
+ end() {
94
+ if (this.#closed) throw new Error('JsonStreamParser: end after end or failure')
95
+ this.#closed = true
96
+ if (this.#state === NUMBER || this.#state === LITERAL) this.#finishWord()
97
+ if (this.#state !== DONE) throw new SyntaxError(`Unexpected end of JSON input at byte ${this.#offset}`)
98
+ const result = this.#result
99
+ this.#result = undefined
100
+ return result
101
+ }
102
+
103
+ #fail(message, i) {
104
+ throw new SyntaxError(`${message} at byte ${this.#offset + i}`)
105
+ }
106
+
107
+ #consume(bytes) {
108
+ const n = bytes.length
109
+ this.#nextQuote = -1
110
+ this.#nextBackslash = -1
111
+ let i = 0
112
+ while (i < n) {
113
+ const state = this.#state
114
+ if (state === STRING) {
115
+ i = this.#string(bytes, i)
116
+ } else if (state === NUMBER || state === LITERAL) {
117
+ i = this.#scanWord(bytes, i)
118
+ } else if (isWhitespace(bytes[i])) {
119
+ i += 1
120
+ } else {
121
+ i = this.#structural(bytes[i], i)
122
+ }
123
+ }
124
+ }
125
+
126
+ #structural(b, i) {
127
+ const state = this.#state
128
+ const isArray = Array.isArray(this.#stack.at(-1))
129
+ if ((state === AFTER || state === ARRAY_FIRST || state === OBJECT_FIRST) && b === (isArray ? 0x5d : 0x7d)) {
130
+ this.#close()
131
+ return i + 1
132
+ }
133
+ if (state === AFTER) {
134
+ if (b !== 0x2c) this.#fail(`Unexpected byte 0x${b.toString(16)} after a value`, i)
135
+ this.#state = isArray ? VALUE : KEY
136
+ return i + 1
137
+ }
138
+ if (state === COLON) {
139
+ if (b !== 0x3a) this.#fail(`Expected ':' after a key, got byte 0x${b.toString(16)}`, i)
140
+ this.#state = VALUE
141
+ return i + 1
142
+ }
143
+ if (state === OBJECT_FIRST || state === KEY) {
144
+ if (b !== QUOTE) this.#fail(`Expected a string key, got byte 0x${b.toString(16)}`, i)
145
+ this.#startToken(STRING, i)
146
+ this.#isKey = true
147
+ return i + 1
148
+ }
149
+ if (state === DONE) this.#fail(`Unexpected byte 0x${b.toString(16)} after the JSON value`, i)
150
+ // VALUE or ARRAY_FIRST
151
+ if (isArray) this.#path[this.#stack.length - 1] = this.#stack.at(-1).length
152
+ if (b === QUOTE) {
153
+ this.#startToken(STRING, i)
154
+ this.#isKey = false
155
+ return i + 1
156
+ }
157
+ if (b === 0x7b || b === 0x5b) {
158
+ this.#stack.push(b === 0x7b ? {} : [])
159
+ this.#path.push(undefined)
160
+ this.#state = b === 0x7b ? OBJECT_FIRST : ARRAY_FIRST
161
+ return i + 1
162
+ }
163
+ if (b === 0x2d || (b >= 0x30 && b <= 0x39)) {
164
+ this.#startToken(NUMBER, i)
165
+ return i
166
+ }
167
+ if (b === 0x74 || b === 0x66 || b === 0x6e) {
168
+ this.#startToken(LITERAL, i)
169
+ return i
170
+ }
171
+ return this.#fail(`Unexpected byte 0x${b.toString(16)} where a value was expected`, i)
172
+ }
173
+
174
+ #startToken(state, i) {
175
+ this.#state = state
176
+ this.#tokenStart = this.#offset + i
177
+ }
178
+
179
+ #string(bytes, start) {
180
+ const n = bytes.length
181
+ let i = start
182
+ if (this.#pendingEscape) {
183
+ this.#pendingEscape = false
184
+ i += 1
185
+ }
186
+ for (;;) {
187
+ if (this.#nextQuote < i) this.#nextQuote = indexOrEnd(bytes, QUOTE, i)
188
+ if (this.#nextBackslash < i) this.#nextBackslash = indexOrEnd(bytes, BACKSLASH, i)
189
+ const quote = this.#nextQuote
190
+ const backslash = this.#nextBackslash
191
+ if (backslash < quote) {
192
+ this.#escaped = true
193
+ if (backslash + 1 === n) {
194
+ this.#pendingEscape = true
195
+ break
196
+ }
197
+ i = backslash + 2
198
+ } else if (quote === n) {
199
+ break
200
+ } else {
201
+ this.#finishString(bytes.subarray(start, quote))
202
+ return quote + 1
203
+ }
204
+ }
205
+ this.#parts.push(bytes.slice(start))
206
+ return n
207
+ }
208
+
209
+ #finishString(tail) {
210
+ let bytes = tail
211
+ if (this.#parts.length > 0) {
212
+ this.#parts.push(tail)
213
+ bytes = concat(this.#parts)
214
+ this.#parts = []
215
+ }
216
+ let value = this.#decoder.decode(bytes)
217
+ if (this.#escaped) {
218
+ // JSON.parse handles the escapes, and rejects raw control bytes.
219
+ this.#escaped = false
220
+ try {
221
+ value = JSON.parse(`"${value}"`)
222
+ } catch (cause) {
223
+ throw new SyntaxError(`Bad string at byte ${this.#tokenStart}`, { cause })
224
+ }
225
+ } else if (hasControlByte(bytes)) {
226
+ throw new SyntaxError(`Bad control character in string at byte ${this.#tokenStart}`)
227
+ }
228
+ if (this.#isKey) {
229
+ this.#path[this.#path.length - 1] = value
230
+ this.#state = COLON
231
+ } else {
232
+ this.#value(this.#onString === undefined ? value : this.#onString(value, this.#path))
233
+ }
234
+ }
235
+
236
+ #scanWord(bytes, i) {
237
+ const n = bytes.length
238
+ const isNumber = this.#state === NUMBER
239
+ let j = i
240
+ while (j < n && (isNumber ? isNumberByte(bytes[j]) : isLetter(bytes[j]))) j += 1
241
+ this.#word += this.#decoder.decode(bytes.subarray(i, j))
242
+ if (j === n) return n
243
+ this.#finishWord()
244
+ return j
245
+ }
246
+
247
+ #finishWord() {
248
+ const word = this.#word
249
+ this.#word = ''
250
+ if (this.#state === NUMBER) {
251
+ if (!NUMBER_RE.test(word)) throw new SyntaxError(`Bad number at byte ${this.#tokenStart}`)
252
+ this.#value(Number(word))
253
+ } else {
254
+ if (!LITERALS.has(word)) throw new SyntaxError(`Bad literal at byte ${this.#tokenStart}`)
255
+ this.#value(LITERALS.get(word))
256
+ }
257
+ }
258
+
259
+ #close() {
260
+ this.#path.pop()
261
+ this.#value(this.#stack.pop())
262
+ }
263
+
264
+ #value(value) {
265
+ const depth = this.#stack.length
266
+ if (depth === 0) {
267
+ this.#result = value
268
+ this.#state = DONE
269
+ return
270
+ }
271
+ const container = this.#stack[depth - 1]
272
+ const key = this.#path[depth - 1]
273
+ if (Array.isArray(container)) {
274
+ container.push(value)
275
+ } else if (key === '__proto__') {
276
+ // JSON.parse defines an own data property; assignment would hit Object.prototype's setter.
277
+ Object.defineProperty(container, key, { value, writable: true, enumerable: true, configurable: true })
278
+ } else {
279
+ container[key] = value
280
+ }
281
+ this.#state = AFTER
282
+ }
283
+ }
@@ -1,19 +1,12 @@
1
- // stasis's `--mock` run-time loader entry (used by `stasis run --mock`). The
2
- // import order is load-bearing:
3
- // 1. the @exodus/stasis-core hooks lib -- evaluating it snapshots stasis's own
4
- // node:fs/path/etc. bindings off the *real* builtins (state.js destructures
5
- // them at module-eval time), so stasis keeps reading/writing through real fs;
6
- // 2. ./mock.js -- which neutralizes the network/timer builtins and fs writers in
7
- // JS and calls module.syncBuiltinESMExports() so user code's later ESM imports
8
- // resolve to the mocked builtins.
9
- // Only THEN install() the hooks. Running mock.js before registration keeps it from
10
- // going through the hooks (so it never becomes the bundle entry), and running it
11
- // after the lib's snapshots leaves stasis's own bindings untouched. This static
12
- // composition is what lets core stay mock-agnostic with no env-var coordination.
1
+ // stasis's `--mock` loader entry. The import order is load-bearing:
2
+ // 1. the core hooks lib -- evaluating it snapshots stasis's own fs bindings off the *real*
3
+ // builtins, so stasis keeps reading/writing through real fs;
4
+ // 2. ./mock.js -- neutralizes the network/timer builtins and fs writers, then calls
5
+ // syncBuiltinESMExports() so user code's later ESM imports resolve to the mocks.
6
+ // Only THEN install() the hooks (so mock.js never goes through them / becomes the bundle entry).
13
7
  import { install } from '@exodus/stasis-core/hooks'
14
- // `export *` (mock.js has no exports) rather than a bare side-effect import only
15
- // to satisfy the no-unassigned-import lint rule; ESM still evaluates it here, in
16
- // source order, after the hooks lib above and before install() below.
8
+ // `export *` (mock.js has no exports) rather than a bare side-effect import, to satisfy the
9
+ // no-unassigned-import lint rule; ESM still evaluates it here in source order.
17
10
  export * from './mock.js'
18
11
 
19
12
  install()