@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5
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 +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- 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 +4 -18
- package/src/diff.js +67 -124
- 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 +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- 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/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -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,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()
|
package/src/loaders/bash.js
CHANGED
|
@@ -1,30 +1,25 @@
|
|
|
1
|
-
// Bash loader:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// A `source "${VAR}/x.sh"` whose path can't be evaluated statically is picked
|
|
10
|
-
// up from its `# shellcheck source=...` directive when one is present.
|
|
11
|
-
|
|
12
|
-
import { realpathSync, statSync } from 'node:fs'
|
|
1
|
+
// Bash loader: `{ sources, resolutions, missing }` from a set of scripts. A
|
|
2
|
+
// .sh/.bash reference landing INSIDE the bundle root must resolve to an in-set
|
|
3
|
+
// script, else it lands in `missing`; everything else (PATH commands, `$VAR`
|
|
4
|
+
// paths, absolute/escaping paths) is best-effort and dropped. A dynamically
|
|
5
|
+
// sourced path can be pinned by a `# shellcheck source=...` directive.
|
|
6
|
+
|
|
7
|
+
import { isUtf8 } from 'node:buffer'
|
|
8
|
+
import { realpathSync } from 'node:fs'
|
|
13
9
|
import { readFile } from 'node:fs/promises'
|
|
14
|
-
import { dirname, extname,
|
|
10
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
15
11
|
|
|
16
12
|
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
13
|
+
import { isFile } from '../resolve-typescript.js'
|
|
14
|
+
import { applyToDir, assertWithinBase } from './paths.js'
|
|
17
15
|
|
|
18
|
-
// The capture class excludes
|
|
19
|
-
//
|
|
20
|
-
// `` `source x.sh` `` or `$(bash x.sh)` — doesn't swallow the closing delimiter
|
|
21
|
-
// into the path (which would silently drop the edge from the bundle).
|
|
16
|
+
// The capture class excludes backtick/`(`/`)` so a ref inside a command
|
|
17
|
+
// substitution (`` `source x.sh` ``, `$(bash x.sh)`) doesn't swallow the closing delimiter.
|
|
22
18
|
|
|
23
19
|
// `source script.sh`, `. script.sh`, `. ./script.sh`
|
|
24
20
|
const SOURCE_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:source|\.)\s+["']?([^\s"';&|#`()]+)["']?/gu
|
|
25
21
|
|
|
26
|
-
// `bash script.sh`,
|
|
27
|
-
// `/bin/sh script.sh`, `/usr/bin/bash script.sh`, etc.
|
|
22
|
+
// `bash`/`sh script.sh`, incl. `/usr/bin/env bash`, `/bin/sh`, `/usr/bin/bash`.
|
|
28
23
|
const EXEC_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*(?:\/usr\/bin\/env\s+|\/bin\/|\/usr\/bin\/)?(?:ba)?sh\s+["']?([^\s"';&|#`()]+)["']?/gu
|
|
29
24
|
|
|
30
25
|
// Direct invocation: `./script.sh`, `../lib/helper.bash`, `/path/to/script.sh`
|
|
@@ -33,21 +28,13 @@ const DIRECT_RE = /(?:^|\n|;|&&|\|\||`|\$\()\s*["']?(\.\.?\/[^\s"';&|#`()]+\.(?:
|
|
|
33
28
|
// Dependency hint comment: `# Depends on: example.sh`
|
|
34
29
|
const COMMENT_RE = /^# Depends on: ([^ ]+\.(?:sh|bash))( |$)/gmu
|
|
35
30
|
|
|
36
|
-
// shellcheck `source=` directive: `# shellcheck source=../lib/config.sh
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// sourced file lives — e.g. `source "${LIB_DIR}/config.sh"`, whose path is a
|
|
40
|
-
// `$VAR` expansion that can't be evaluated statically. The path is resolved
|
|
41
|
-
// relative to the script (matching shellcheck's default). `source=/dev/null`
|
|
42
|
-
// is shellcheck's "don't follow this source" sentinel and is ignored below.
|
|
31
|
+
// shellcheck `source=` directive: `# shellcheck source=../lib/config.sh`, the
|
|
32
|
+
// explicit way to point at a dynamically sourced (`$VAR`) file. Resolved relative
|
|
33
|
+
// to the script. `source=/dev/null` is shellcheck's "don't follow" sentinel, ignored below.
|
|
43
34
|
const SHELLCHECK_SOURCE_RE = /#\s*shellcheck\s[^\n]*?\bsource=["']?([^\s"'#]+)["']?/gu
|
|
44
35
|
|
|
45
|
-
// Extract
|
|
46
|
-
//
|
|
47
|
-
// here — they are never bundle-able file paths; a `source "${VAR}/x.sh"` whose
|
|
48
|
-
// real location can't be computed statically should carry a `# shellcheck
|
|
49
|
-
// source=` directive instead. The remaining strings are candidate references;
|
|
50
|
-
// resolveBashCall decides which name a real file.
|
|
36
|
+
// Extract candidate file references. `$VAR` expansions and `-flags` are dropped
|
|
37
|
+
// (never bundle-able); resolveBashCall decides which of the rest name a real file.
|
|
51
38
|
export function extractBashCalls(content) {
|
|
52
39
|
const calls = new Set()
|
|
53
40
|
for (const re of [SOURCE_RE, EXEC_RE, DIRECT_RE, COMMENT_RE, SHELLCHECK_SOURCE_RE]) {
|
|
@@ -60,63 +47,27 @@ export function extractBashCalls(content) {
|
|
|
60
47
|
return [...calls]
|
|
61
48
|
}
|
|
62
49
|
|
|
63
|
-
// Resolve a bash reference to a baseDir-relative POSIX path. Existence is
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// graph); with neither, only path computation happens and unvalidated names
|
|
67
|
-
// return null.
|
|
68
|
-
//
|
|
69
|
-
// Reference kinds are treated differently:
|
|
70
|
-
// - absolute (`/x`): mapped to project-relative by stripping the leading
|
|
71
|
-
// slash, accepted only if that file actually exists in the set/on disk
|
|
72
|
-
// (so system paths like /etc/profile.d/x.sh are dropped, not bundled).
|
|
73
|
-
// - relative / project-relative WITH a slash (`./x`, `../x`, `lib/x.sh`):
|
|
74
|
-
// resolved against the caller's directory and returned as a computed
|
|
75
|
-
// path WITHOUT an existence check — the walk's readFile surfaces a
|
|
76
|
-
// missing target and the tree flags or drops it, mirroring the Solidity
|
|
77
|
-
// loader. Traversal above baseDir returns null rather than clamping, so
|
|
78
|
-
// an escaping `../x.sh` is treated as external (never flagged missing).
|
|
79
|
-
// - bare name (`helper.sh`, but also `grep`, `node`): could be a sibling
|
|
80
|
-
// script or a PATH command, so it is accepted only when it names a real
|
|
81
|
-
// file — caller's dir, then project root, then (knownFiles only) any
|
|
82
|
-
// in-set file by basename.
|
|
50
|
+
// Resolve a bash reference to a baseDir-relative POSIX path. Existence is checked
|
|
51
|
+
// against `knownFiles` (tree mode) or the filesystem under `baseDir` (walk mode);
|
|
52
|
+
// with neither, only path computation happens. Handling differs by kind (below).
|
|
83
53
|
export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
84
54
|
const present = (p) => {
|
|
85
55
|
if (!p) return false
|
|
86
56
|
if (knownFiles) return knownFiles.has(p)
|
|
87
|
-
if (baseDir)
|
|
88
|
-
try {
|
|
89
|
-
return statSync(join(baseDir, p)).isFile()
|
|
90
|
-
} catch {
|
|
91
|
-
return false
|
|
92
|
-
}
|
|
93
|
-
}
|
|
57
|
+
if (baseDir) return isFile(join(baseDir, p))
|
|
94
58
|
return false
|
|
95
59
|
}
|
|
96
60
|
|
|
97
|
-
|
|
98
|
-
const resolveRel = (r) =>
|
|
99
|
-
const parts = [...(fromDir ? fromDir.split('/') : []), ...r.split('/')]
|
|
100
|
-
const out = []
|
|
101
|
-
for (const part of parts) {
|
|
102
|
-
if (part === '.' || part === '') continue
|
|
103
|
-
if (part === '..') {
|
|
104
|
-
if (out.length === 0) return null // escapes baseDir
|
|
105
|
-
out.pop()
|
|
106
|
-
} else {
|
|
107
|
-
out.push(part)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return out.join('/') || null
|
|
111
|
-
}
|
|
61
|
+
// Null when it escapes baseDir or names baseDir itself.
|
|
62
|
+
const resolveRel = (r) => applyToDir(fromFile, r.split('/')) || null
|
|
112
63
|
|
|
113
|
-
// Absolute → project-relative,
|
|
64
|
+
// Absolute → project-relative, accepted only if it exists (so `/etc/...` is dropped).
|
|
114
65
|
if (ref.startsWith('/')) {
|
|
115
66
|
const stripped = ref.replace(/^\/+/u, '')
|
|
116
67
|
return present(stripped) ? stripped : null
|
|
117
68
|
}
|
|
118
69
|
|
|
119
|
-
// Path with a slash → computed
|
|
70
|
+
// Path with a slash → computed against the caller's dir; existence deferred to the caller.
|
|
120
71
|
if (ref.includes('/')) return resolveRel(ref)
|
|
121
72
|
|
|
122
73
|
// Bare name → only accept a real file.
|
|
@@ -132,19 +83,13 @@ export function resolveBashCall(ref, fromFile, { knownFiles, baseDir } = {}) {
|
|
|
132
83
|
return null
|
|
133
84
|
}
|
|
134
85
|
|
|
135
|
-
//
|
|
136
|
-
// an extensionless `source ./env`, which we don't treat as a missing script
|
|
137
|
-
// when absent).
|
|
86
|
+
// True for `.sh`/`.bash` refs; an extensionless `source ./env` isn't flagged missing when absent.
|
|
138
87
|
const hasShellScriptExt = (ref) => ref.endsWith('.sh') || ref.endsWith('.bash')
|
|
139
88
|
|
|
140
|
-
// Build the
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// References that resolve to null are external and tolerated: bare names
|
|
145
|
-
// (PATH commands / runtime-cwd scripts), absolute `/etc/...` paths, `$VAR`
|
|
146
|
-
// paths, and `../` paths that escape the root — none can live in the bundle.
|
|
147
|
-
// Self-references and non-script extensions are dropped without flagging.
|
|
89
|
+
// Build the triple from already-loaded scripts. A ref is `missing` (fatal) only
|
|
90
|
+
// when it resolves INSIDE the bundle root but wasn't collected (a dangling
|
|
91
|
+
// `source ./lib.sh`); null-resolving refs are external and tolerated, and
|
|
92
|
+
// self-refs/non-script exts are dropped silently.
|
|
148
93
|
export function buildBashTree(sources) {
|
|
149
94
|
const knownFiles = new Set(sources.keys())
|
|
150
95
|
const resolutions = new Map()
|
|
@@ -152,9 +97,8 @@ export function buildBashTree(sources) {
|
|
|
152
97
|
for (const [path, content] of sources) {
|
|
153
98
|
const specMap = new Map()
|
|
154
99
|
for (const ref of extractBashCalls(content)) {
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// file is a genuine dangling local dependency; a null target is external.
|
|
100
|
+
// Slash branch returns an unchecked in-bundle path, so a non-null target not
|
|
101
|
+
// in knownFiles is a genuine dangling dep; a null target is external.
|
|
158
102
|
const target = resolveBashCall(ref, path, { knownFiles })
|
|
159
103
|
if (target && knownFiles.has(target)) {
|
|
160
104
|
if (target !== path) specMap.set(ref, target) // drop self-references
|
|
@@ -168,12 +112,15 @@ export function buildBashTree(sources) {
|
|
|
168
112
|
return { sources, resolutions, missing }
|
|
169
113
|
}
|
|
170
114
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
115
|
+
// A script's text. One that isn't UTF-8 is refused rather than carried with U+FFFD in it.
|
|
116
|
+
async function readScript(baseDir, relPath) {
|
|
117
|
+
const bytes = await readFile(join(baseDir, relPath))
|
|
118
|
+
if (!isUtf8(bytes)) throw new Error(`Shell script is not valid UTF-8: ${relPath}`)
|
|
119
|
+
return bytes.toString('utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Walk from `entries` following resolved refs, reading each script once; a ref
|
|
123
|
+
// missing on disk warns and is skipped without aborting the walk.
|
|
177
124
|
export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
178
125
|
const sources = new Map()
|
|
179
126
|
const realBase = realpathSync(baseDir)
|
|
@@ -185,14 +132,13 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
185
132
|
toLoad.map(async (relPath) => {
|
|
186
133
|
try {
|
|
187
134
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
188
|
-
return [relPath, await
|
|
135
|
+
return [relPath, await readScript(baseDir, relPath)]
|
|
189
136
|
} catch (err) {
|
|
190
137
|
if (err.code === 'ENOENT') {
|
|
191
138
|
console.warn(`[loader.bash] Missing file: ${relPath}`)
|
|
192
139
|
return null
|
|
193
140
|
}
|
|
194
|
-
// A slash
|
|
195
|
-
// readFile then throws EISDIR. Skip it rather than aborting the walk.
|
|
141
|
+
// A slash ref can resolve to a directory (`source ./lib`) → EISDIR; skip, don't abort.
|
|
196
142
|
if (err.code === 'EISDIR') {
|
|
197
143
|
console.warn(`[loader.bash] Skipping directory reference: ${relPath}`)
|
|
198
144
|
return null
|
|
@@ -218,22 +164,9 @@ export async function collectBashFilesFromDisk(baseDir, entries) {
|
|
|
218
164
|
return sources
|
|
219
165
|
}
|
|
220
166
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
function assertWithinBase(baseDir, candidate, label) {
|
|
225
|
-
if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
|
|
226
|
-
const rel = relative(baseDir, resolve(baseDir, candidate)).split(/[\\/]/u).join('/')
|
|
227
|
-
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
228
|
-
throw new Error(`${label} escapes baseDir: ${candidate}`)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// High-level: takes a `.sh.txt` listing file whose lines are the scripts to
|
|
233
|
-
// load (`.sh`, `.bash`, or extensionless), resolved relative to the listing.
|
|
234
|
-
// Unlike loadSolidity, the listing is the authoritative manifest — bash
|
|
235
|
-
// sourcing is too dynamic to walk reliably — so exactly the listed files are
|
|
236
|
-
// read and resolutions are computed among them. Returns { sources, resolutions, missing }.
|
|
167
|
+
// High-level entry: reads the scripts named in a `.sh.txt` listing (relative to
|
|
168
|
+
// it). Unlike loadRust, the listing is authoritative — bash sourcing is too
|
|
169
|
+
// dynamic to walk — so exactly the listed files are read, resolutions computed among them.
|
|
237
170
|
export async function loadBash(shTxtFile) {
|
|
238
171
|
const baseDir = dirname(resolve(shTxtFile))
|
|
239
172
|
const listing = await readFile(shTxtFile, 'utf8')
|
|
@@ -250,7 +183,7 @@ export async function loadBash(shTxtFile) {
|
|
|
250
183
|
const reads = await Promise.all(
|
|
251
184
|
lines.map(async (relPath) => {
|
|
252
185
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
253
|
-
return [relPath, await
|
|
186
|
+
return [relPath, await readScript(baseDir, relPath)]
|
|
254
187
|
}),
|
|
255
188
|
)
|
|
256
189
|
return buildBashTree(new Map(reads))
|