@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.
- package/README.md +32 -8
- package/bin/stasis.js +389 -214
- package/package.json +52 -12
- package/src/audit-corrections.js +96 -0
- package/src/audit.js +289 -64
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +14 -22
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +96 -128
- package/src/cmd/bundle.js +1121 -654
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +7 -19
- package/src/diff.js +80 -129
- package/src/installed-commits.js +182 -0
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1716 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +375 -402
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +370 -0
- package/src/resolve-node.js +419 -0
- package/src/resolve-typescript.js +541 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +120 -97
- package/src/scan.js +348 -180
- package/src/vfs-bundle/entries.js +144 -0
- package/src/vfs-bundle/github.js +202 -0
- package/src/vfs-bundle/prisma.js +439 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +647 -0
- package/src/vfs-bundle.js +80 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- 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
|
+
}
|
package/src/loader-mock.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
// stasis's `--mock`
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
15
|
-
//
|
|
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()
|