@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3
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 +16 -2
- package/bin/stasis.js +173 -161
- package/package.json +11 -5
- package/src/apis/npm/semver.cjs +0 -2
- package/src/audit.js +161 -22
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/cmd/build.js +93 -123
- package/src/cmd/bundle.js +470 -427
- package/src/cmd/diff.js +2 -19
- package/src/cmd/sbom.js +1 -7
- package/src/diff.js +61 -118
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +33 -81
- package/src/loaders/php.js +143 -282
- package/src/loaders/rust.js +37 -93
- package/src/loaders/solidity.js +27 -52
- package/src/metro-resolver.js +165 -0
- package/src/metro-transformer.js +3 -2
- package/src/mock.js +54 -160
- package/src/parse.js +4 -16
- package/src/sbom.js +37 -79
- package/src/scan.js +166 -124
- package/src/cmd/extract.js +0 -193
package/src/loaders/php.js
CHANGED
|
@@ -1,36 +1,7 @@
|
|
|
1
|
-
// PHP include-graph + Composer-autoload loader
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// PHP source. Two dependency mechanisms are followed, neither of which executes
|
|
6
|
-
// any PHP:
|
|
7
|
-
//
|
|
8
|
-
// 1. Explicit includes -- `require` / `require_once` / `include` /
|
|
9
|
-
// `include_once` with a string-literal path. Three argument shapes are
|
|
10
|
-
// understood:
|
|
11
|
-
// require_once __DIR__ . '/lib/Foo.php'; // dir-relative (modern)
|
|
12
|
-
// include dirname(__FILE__) . '/Bar.php'; // dir-relative (legacy)
|
|
13
|
-
// require 'helpers.php'; // bare / include_path
|
|
14
|
-
// These are treated as *strict*: an unresolved include is reported in
|
|
15
|
-
// `missing` and makes `buildPhpBundle` refuse to emit a bundle, because a
|
|
16
|
-
// literal require with no target is a real, load-time error.
|
|
17
|
-
//
|
|
18
|
-
// 2. Composer autoloading -- classes pulled in lazily by the autoloader
|
|
19
|
-
// `require 'vendor/autoload.php'` registers. Those class files are never
|
|
20
|
-
// named in a literal `require`, so the include scan alone misses them.
|
|
21
|
-
// When a `composer.json` (or generated `vendor/composer/autoload_*.php`
|
|
22
|
-
// map) is present, we read the PSR-4 / PSR-0 / classmap / files autoload
|
|
23
|
-
// config, extract the classes each file actually *references* (a `use`
|
|
24
|
-
// statement only creates an alias and loads nothing, so it is not itself a
|
|
25
|
-
// dependency -- references come from `new`/`extends`/`implements`/
|
|
26
|
-
// `instanceof`/`catch`, `Foo::bar`, type hints, attributes, fully-qualified
|
|
27
|
-
// names, and in-body trait `use`), resolve each to a file the same way
|
|
28
|
-
// Composer's ClassLoader would, and follow it. This is
|
|
29
|
-
// deliberately *best-effort*: PHP class loading is dynamic in the general
|
|
30
|
-
// case (`new $class`, reflection), and most unresolved references are
|
|
31
|
-
// built-in/global classes (`\Exception`, `\DateTime`) or extension classes
|
|
32
|
-
// that live in no autoload map -- so an unresolved class reference is
|
|
33
|
-
// silently skipped rather than treated as a missing dependency.
|
|
1
|
+
// PHP include-graph + Composer-autoload loader. Statically scans PHP (never runs
|
|
2
|
+
// it) into `{ sources, resolutions, missing }`. Literal includes are strict
|
|
3
|
+
// (unresolved -> `missing`); Composer-autoloaded class refs are best-effort
|
|
4
|
+
// (unresolved -> silently skipped).
|
|
34
5
|
|
|
35
6
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
36
7
|
import { readFile } from 'node:fs/promises'
|
|
@@ -38,15 +9,11 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
|
38
9
|
|
|
39
10
|
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
40
11
|
|
|
41
|
-
//
|
|
12
|
+
// Cap concurrent reads per wave to avoid EMFILE on large directory globs.
|
|
42
13
|
const READ_CONCURRENCY = 64
|
|
43
14
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
// End index (exclusive) of the heredoc/nowdoc opening at `start` (`<<<`), or
|
|
47
|
-
// `start` itself if it isn't a valid opener. Consumes to EOF when unterminated.
|
|
48
|
-
// Recognises the indented closing label (PHP 7.3+) and the leading quote of
|
|
49
|
-
// nowdoc/quoted-heredoc labels.
|
|
15
|
+
// End index (exclusive) of the heredoc/nowdoc opener at `start`, or `start` if
|
|
16
|
+
// not a valid opener. Consumes to EOF when unterminated.
|
|
50
17
|
function heredocEnd(code, start) {
|
|
51
18
|
const n = code.length
|
|
52
19
|
let j = start + 3
|
|
@@ -76,15 +43,9 @@ function heredocEnd(code, start) {
|
|
|
76
43
|
return n
|
|
77
44
|
}
|
|
78
45
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// (newlines preserved), so the output is the same length as the input and byte
|
|
83
|
-
// offsets stay aligned. Strings are scanned in the same loop as comments, so a
|
|
84
|
-
// `//`/`#` inside a string is never mistaken for a comment, and `#[` (a PHP 8
|
|
85
|
-
// attribute) is left intact. A hand-written scanner (rather than one big regex)
|
|
86
|
-
// keeps this strictly linear -- immune to the catastrophic backtracking a regex
|
|
87
|
-
// suffers on unterminated strings/heredocs in adversarial input.
|
|
46
|
+
// Blank everything that isn't executable PHP (comments, heredocs/nowdocs, and
|
|
47
|
+
// unless `keepStrings` string literals) to spaces, preserving newlines so byte
|
|
48
|
+
// offsets stay aligned with the input. `#[` (a PHP 8 attribute) is left intact.
|
|
88
49
|
function maskNonCode(code, keepStrings) {
|
|
89
50
|
const n = code.length
|
|
90
51
|
const blank = (s) => s.replace(/[^\n]/gu, ' ')
|
|
@@ -122,18 +83,14 @@ function maskNonCode(code, keepStrings) {
|
|
|
122
83
|
return out
|
|
123
84
|
}
|
|
124
85
|
|
|
125
|
-
//
|
|
126
|
-
// strings). See maskNonCode.
|
|
86
|
+
// Blank comments/heredocs, keep string literals (include paths live in strings).
|
|
127
87
|
function maskCommentsAndHeredocs(content) {
|
|
128
88
|
return maskNonCode(content, true)
|
|
129
89
|
}
|
|
130
90
|
|
|
131
|
-
// Scan
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// Knowing the real boundaries means quote pairing is never guessed: an include
|
|
135
|
-
// keyword sitting inside a string is recognisable, and a string can't be
|
|
136
|
-
// mis-paired with a quote belonging to the next concatenated string.
|
|
91
|
+
// Scan string literals -> { start, end, value, quote } with PHP single-quote
|
|
92
|
+
// unescaping (only `\\` and the closing quote are escapes; other backslashes
|
|
93
|
+
// literal -- good enough for path values).
|
|
137
94
|
function stringLiterals(code) {
|
|
138
95
|
const out = []
|
|
139
96
|
let i = 0
|
|
@@ -163,25 +120,19 @@ function stringLiterals(code) {
|
|
|
163
120
|
return out
|
|
164
121
|
}
|
|
165
122
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
// (`$x->require(...)`), scope resolutions (`Foo::include(...)`) and longer
|
|
170
|
-
// identifiers from matching; the trailing `\b` stops `require` from matching
|
|
171
|
-
// inside `requireConfig`. Matches inside string literals are filtered out by
|
|
172
|
-
// the caller using the real string spans.
|
|
123
|
+
// Include keyword. The lookbehind rejects method/scope-resolution calls
|
|
124
|
+
// (`$x->require`, `Foo::include`); the trailing `\b` stops matching inside
|
|
125
|
+
// `requireConfig`. String-literal matches are filtered out by the caller.
|
|
173
126
|
const PHP_INCLUDE_KEYWORD_RE = /(?<![\w$>:])(?:require_once|require|include_once|include)\b/gidu
|
|
174
127
|
|
|
175
|
-
// Directory anchor
|
|
176
|
-
//
|
|
177
|
-
// up -- the idiom for locating bootstrap/vendor/config relative to a file).
|
|
128
|
+
// Directory anchor (sticky): `__DIR__`/`dirname(__FILE__)` (file's own dir) or
|
|
129
|
+
// `dirname(__DIR__[, N])` (N parent levels up).
|
|
178
130
|
const PHP_DIR_ANCHOR_RE = /__DIR__\b|dirname\s*\(\s*(__FILE__|__DIR__)\s*(?:,\s*(\d+)\s*)?\)/y
|
|
179
131
|
// Anchor starts, for scanning path literals anywhere (not just after `require`).
|
|
180
132
|
const PHP_DIR_ANCHOR_FIND_RE = /(?<![\w$>])(?:__DIR__\b|dirname\s*\(\s*(?:__FILE__|__DIR__))/gu
|
|
181
133
|
|
|
182
|
-
// Laravel path helpers
|
|
183
|
-
//
|
|
184
|
-
// `config_path('app.php')`, … name real PHP files that the framework require()s.
|
|
134
|
+
// Laravel path helpers resolving a string arg relative to the project root (or a
|
|
135
|
+
// fixed subdirectory); they name real PHP files the framework require()s.
|
|
185
136
|
const PHP_PATH_HELPERS = new Map([
|
|
186
137
|
['base_path', ''],
|
|
187
138
|
['app_path', 'app'],
|
|
@@ -205,8 +156,7 @@ function skipWs(code, pos) {
|
|
|
205
156
|
}
|
|
206
157
|
|
|
207
158
|
// If a directory anchor starts at `pos`, return { end, up } (up = parent levels
|
|
208
|
-
// above the file's
|
|
209
|
-
// `dirname(__DIR__[, N])`); otherwise null.
|
|
159
|
+
// above the file's dir; 0 for `__DIR__`/`dirname(__FILE__)`); else null.
|
|
210
160
|
function matchDirAnchor(code, pos) {
|
|
211
161
|
PHP_DIR_ANCHOR_RE.lastIndex = pos
|
|
212
162
|
const m = PHP_DIR_ANCHOR_RE.exec(code)
|
|
@@ -215,9 +165,8 @@ function matchDirAnchor(code, pos) {
|
|
|
215
165
|
return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
|
|
216
166
|
}
|
|
217
167
|
|
|
218
|
-
// Normalise an include path to a specifier. Dir-relative paths
|
|
219
|
-
//
|
|
220
|
-
// dir-relative path means "the file's own directory" (`.`).
|
|
168
|
+
// Normalise an include path to a specifier. Dir-relative paths get a `./` prefix
|
|
169
|
+
// with the leading separator stripped; empty means the file's own dir (`.`).
|
|
221
170
|
function normalizeIncludePath(raw, dirRelative) {
|
|
222
171
|
if (!dirRelative) return raw
|
|
223
172
|
const rel = raw.replace(/^\/+/u, '')
|
|
@@ -225,14 +174,10 @@ function normalizeIncludePath(raw, dirRelative) {
|
|
|
225
174
|
return rel.startsWith('.') ? rel : `./${rel}`
|
|
226
175
|
}
|
|
227
176
|
|
|
228
|
-
// Parse an include/path argument
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// variable/function/constant or `"…$interp…"`. Parent levels from the anchor
|
|
233
|
-
// (`dirname(__DIR__, N)`) are folded in as leading `/..`. Returns null when
|
|
234
|
-
// there's neither an anchor nor any static string. The accumulated `prefix` is
|
|
235
|
-
// the static path up to the first dynamic piece.
|
|
177
|
+
// Parse an include/path argument at `start`: an optional directory anchor then a
|
|
178
|
+
// concatenation chain of string literals (static literals fold together). Goes
|
|
179
|
+
// `dynamic` at the first variable/function/constant/interpolation; `prefix` is
|
|
180
|
+
// the static path up to there. Returns null with neither anchor nor static string.
|
|
236
181
|
function parseArgPath(code, start, strings) {
|
|
237
182
|
const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
|
|
238
183
|
let i = start
|
|
@@ -243,7 +188,7 @@ function parseArgPath(code, start, strings) {
|
|
|
243
188
|
i = skipWs(code, anchor.end)
|
|
244
189
|
dirRelative = true
|
|
245
190
|
up = anchor.up
|
|
246
|
-
if (code[i] !== '.') return null
|
|
191
|
+
if (code[i] !== '.') return null
|
|
247
192
|
i = skipWs(code, i + 1)
|
|
248
193
|
}
|
|
249
194
|
|
|
@@ -252,25 +197,22 @@ function parseArgPath(code, start, strings) {
|
|
|
252
197
|
let sawStatic = false
|
|
253
198
|
for (;;) {
|
|
254
199
|
const lit = stringAt(i)
|
|
255
|
-
if (!lit) { dynamic = true; break }
|
|
200
|
+
if (!lit) { dynamic = true; break }
|
|
256
201
|
const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
|
|
257
202
|
if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
|
|
258
203
|
prefix += lit.value
|
|
259
204
|
sawStatic = true
|
|
260
205
|
i = skipWs(code, lit.end)
|
|
261
|
-
if (code[i] === '.') { i = skipWs(code, i + 1); continue }
|
|
206
|
+
if (code[i] === '.') { i = skipWs(code, i + 1); continue }
|
|
262
207
|
break
|
|
263
208
|
}
|
|
264
209
|
if (!dirRelative && !sawStatic) return null
|
|
265
210
|
return { dirRelative, up, prefix, dynamic, sawStatic }
|
|
266
211
|
}
|
|
267
212
|
|
|
268
|
-
// Scan
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// target, e.g. Laravel's `require __DIR__ . "/.../components/$view.php"`). Works
|
|
272
|
-
// on comment/heredoc-masked code and real string-literal boundaries (never regex
|
|
273
|
-
// quote-pairing); keyword matches inside a string are skipped.
|
|
213
|
+
// Scan `require`/`include` statements, classifying each as a concrete `file` or
|
|
214
|
+
// -- when the path is dynamic but has a static directory prefix -- a `dir` whose
|
|
215
|
+
// `.php` files are all bundled (any could be the runtime target).
|
|
274
216
|
function scanIncludes(content) {
|
|
275
217
|
const code = maskCommentsAndHeredocs(content)
|
|
276
218
|
const strings = stringLiterals(code)
|
|
@@ -289,23 +231,19 @@ function scanIncludes(content) {
|
|
|
289
231
|
if (sawStatic) out.push({ kind: 'file', spec: normalizeIncludePath(prefix, dirRelative) })
|
|
290
232
|
continue
|
|
291
233
|
}
|
|
292
|
-
// Dynamic path: fall back to the static
|
|
293
|
-
// separator). A bare path with no separator has no usable directory.
|
|
234
|
+
// Dynamic path: fall back to the static dir prefix; a bare path with no separator has none.
|
|
294
235
|
if (!dirRelative && !prefix.includes('/')) continue
|
|
295
236
|
const lastSlash = prefix.lastIndexOf('/')
|
|
296
237
|
let dir = lastSlash >= 0 ? prefix.slice(0, lastSlash) : ''
|
|
297
|
-
if (dir === '' && up > 0) dir = '/..'.repeat(up)
|
|
238
|
+
if (dir === '' && up > 0) dir = '/..'.repeat(up)
|
|
298
239
|
out.push({ kind: 'dir', spec: normalizeIncludePath(dir, dirRelative) })
|
|
299
240
|
}
|
|
300
241
|
return out
|
|
301
242
|
}
|
|
302
243
|
|
|
303
|
-
// Dir-anchored static `.php` path literals anywhere in the file (not
|
|
304
|
-
// `require
|
|
305
|
-
//
|
|
306
|
-
// '/../routes/web.php', …)`. These are best-effort: bundled only if they resolve
|
|
307
|
-
// to a file that exists (see collectPhpFilesFromDisk), so a path that's just a
|
|
308
|
-
// string and never loaded does no harm.
|
|
244
|
+
// Dir-anchored static `.php` path literals anywhere in the file (not just after
|
|
245
|
+
// `require`) -- frameworks pass these to code that require()s them internally.
|
|
246
|
+
// Best-effort: bundled only if they resolve to an existing file.
|
|
309
247
|
function scanPathLiterals(content) {
|
|
310
248
|
const code = maskCommentsAndHeredocs(content)
|
|
311
249
|
const strings = stringLiterals(code)
|
|
@@ -313,7 +251,7 @@ function scanPathLiterals(content) {
|
|
|
313
251
|
const stringAt = (pos) => strings.find(({ start }) => start === pos)
|
|
314
252
|
|
|
315
253
|
const out = []
|
|
316
|
-
// Dir-anchored literals: `__DIR__ . '/x.php'` (
|
|
254
|
+
// Dir-anchored literals: `__DIR__ . '/x.php'` (resolve against the file).
|
|
317
255
|
for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
|
|
318
256
|
if (inString(m.index)) continue
|
|
319
257
|
const arg = parseArgPath(code, m.index, strings)
|
|
@@ -321,8 +259,7 @@ function scanPathLiterals(content) {
|
|
|
321
259
|
const spec = normalizeIncludePath(arg.prefix, true)
|
|
322
260
|
if (spec.endsWith('.php')) out.push({ spec, anchor: 'file' })
|
|
323
261
|
}
|
|
324
|
-
// Laravel path helpers: `base_path('routes/web.php')`
|
|
325
|
-
// against the project root, with the helper's fixed subdirectory prefixed).
|
|
262
|
+
// Laravel path helpers: `base_path('routes/web.php')` (resolve against project root).
|
|
326
263
|
for (const m of code.matchAll(PHP_PATH_HELPER_FIND_RE)) {
|
|
327
264
|
if (inString(m.index)) continue
|
|
328
265
|
const lit = stringAt(skipWs(code, m.index + m[0].length))
|
|
@@ -340,16 +277,14 @@ export function extractPhpImports(content) {
|
|
|
340
277
|
return scanIncludes(content).filter((e) => e.kind === 'file').map((e) => e.spec)
|
|
341
278
|
}
|
|
342
279
|
|
|
343
|
-
// Directory specifiers for dynamic includes
|
|
344
|
-
//
|
|
280
|
+
// Directory specifiers for dynamic includes; every `.php` file directly inside is
|
|
281
|
+
// a candidate target.
|
|
345
282
|
export function extractPhpImportDirs(content) {
|
|
346
283
|
return scanIncludes(content).filter((e) => e.kind === 'dir').map((e) => e.spec)
|
|
347
284
|
}
|
|
348
285
|
|
|
349
|
-
// `.php` path literals used outside a `require
|
|
350
|
-
//
|
|
351
|
-
// (`base_path('routes/x.php')`, `anchor: 'root'`). Frameworks pass these to code
|
|
352
|
-
// that require()s them. De-duplicated; best-effort (bundled only if they exist).
|
|
286
|
+
// `.php` path literals used outside a `require` -- dir-anchored (`anchor: 'file'`)
|
|
287
|
+
// or via a Laravel path helper (`anchor: 'root'`). De-duplicated; best-effort.
|
|
353
288
|
export function extractPhpPathRefs(content) {
|
|
354
289
|
const seen = new Set()
|
|
355
290
|
return scanPathLiterals(content).filter(({ spec, anchor }) => {
|
|
@@ -360,9 +295,8 @@ export function extractPhpPathRefs(content) {
|
|
|
360
295
|
})
|
|
361
296
|
}
|
|
362
297
|
|
|
363
|
-
// Apply `segments`
|
|
364
|
-
//
|
|
365
|
-
// or null when traversal escapes above the project root (rather than clamping).
|
|
298
|
+
// Apply `segments` on `fromFile`'s directory, resolving '.'/'..'. Returns a
|
|
299
|
+
// baseDir-relative POSIX path, or null when traversal escapes the root.
|
|
366
300
|
function applyToDir(fromFile, segments) {
|
|
367
301
|
const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
|
|
368
302
|
const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
|
|
@@ -395,9 +329,9 @@ function isDirOnDisk(baseDir, rel) {
|
|
|
395
329
|
}
|
|
396
330
|
}
|
|
397
331
|
|
|
398
|
-
// Resolve a directory specifier
|
|
399
|
-
//
|
|
400
|
-
//
|
|
332
|
+
// Resolve a directory specifier to an existing baseDir-relative dir, or null.
|
|
333
|
+
// `./`/`../` resolve against the including file; a bare spec tries file- then
|
|
334
|
+
// project-relative.
|
|
401
335
|
export function resolvePhpDir(spec, fromFile, baseDir) {
|
|
402
336
|
if (spec.startsWith('.')) {
|
|
403
337
|
const rel = applyToDir(fromFile, spec.split('/'))
|
|
@@ -414,9 +348,8 @@ export function resolvePhpDir(spec, fromFile, baseDir) {
|
|
|
414
348
|
return null
|
|
415
349
|
}
|
|
416
350
|
|
|
417
|
-
// The `.php` files directly in `dir` (baseDir-relative)
|
|
418
|
-
//
|
|
419
|
-
// the directory itself, and recursing could pull in an unbounded subtree.
|
|
351
|
+
// The `.php` files directly in `dir` (baseDir-relative). Non-recursive: recursing
|
|
352
|
+
// could pull in an unbounded subtree.
|
|
420
353
|
function listPhpFiles(baseDir, dir) {
|
|
421
354
|
try {
|
|
422
355
|
return readdirSync(join(baseDir, dir), { withFileTypes: true })
|
|
@@ -427,14 +360,10 @@ function listPhpFiles(baseDir, dir) {
|
|
|
427
360
|
}
|
|
428
361
|
}
|
|
429
362
|
|
|
430
|
-
// Resolve a PHP include specifier to a baseDir-relative POSIX path.
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// include_path, so it is disambiguated by what's actually on disk: a file
|
|
435
|
-
// next to the including file wins, otherwise a project-root-relative match.
|
|
436
|
-
// Both require `baseDir`; absolute specifiers are rejected outright.
|
|
437
|
-
// Returns null when no strategy resolves the include.
|
|
363
|
+
// Resolve a PHP include specifier to a baseDir-relative POSIX path. `./`/`../`
|
|
364
|
+
// resolve against the including file (escaping the root -> null); a bare specifier
|
|
365
|
+
// tries a file next to the includer, then project-root-relative. Absolute
|
|
366
|
+
// specifiers are rejected. Returns null when nothing resolves.
|
|
438
367
|
export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
439
368
|
if (specifier.startsWith('.')) {
|
|
440
369
|
return applyToDir(fromFile, specifier.split('/'))
|
|
@@ -453,14 +382,10 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
|
453
382
|
return null
|
|
454
383
|
}
|
|
455
384
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// Resolves against baseDir and re-derives the relative path so that *interior*
|
|
461
|
-
// `..` segments (e.g. `src/foo/../../../secret.php`) that escape the root are
|
|
462
|
-
// rejected -- a leading-`..`/absolute check alone would let those through and
|
|
463
|
-
// read files outside the project into the bundle.
|
|
385
|
+
// Normalise a project-root-relative autoload path to POSIX baseDir-relative, or
|
|
386
|
+
// null when it escapes root. Re-derives via resolve() so interior `..` escapes
|
|
387
|
+
// (`src/foo/../../../secret.php`) are caught -- a leading-`..`/absolute check
|
|
388
|
+
// alone would let files outside the project into the bundle.
|
|
464
389
|
function normalizeProjectRel(baseDir, p) {
|
|
465
390
|
const rel = relative(baseDir, resolve(baseDir, p.replace(/\\/gu, '/'))).split(/[\\/]/u).join('/')
|
|
466
391
|
if (rel === '') return ''
|
|
@@ -468,11 +393,9 @@ function normalizeProjectRel(baseDir, p) {
|
|
|
468
393
|
return rel
|
|
469
394
|
}
|
|
470
395
|
|
|
471
|
-
// Evaluate a generated-autoload path
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
// or null when the expression contains anything we don't understand (a
|
|
475
|
-
// function call, an unknown variable) so we skip it rather than guess.
|
|
396
|
+
// Evaluate a generated-autoload path expr (`$baseDir . '/src'`, a bare quoted
|
|
397
|
+
// string) given the `$baseDir`/`$vendorDir` values. Returns null on anything
|
|
398
|
+
// unrecognised (function call, unknown variable) rather than guessing.
|
|
476
399
|
function evalPathExpr(expr, vars) {
|
|
477
400
|
const s = expr.trim().replace(/,+$/u, '').trim()
|
|
478
401
|
let out = ''
|
|
@@ -480,9 +403,8 @@ function evalPathExpr(expr, vars) {
|
|
|
480
403
|
const re = /\$(\w+)|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"|\.|\s+/gy
|
|
481
404
|
let m
|
|
482
405
|
while ((m = re.exec(s)) !== null) {
|
|
483
|
-
// A sticky regex resets lastIndex to 0
|
|
484
|
-
//
|
|
485
|
-
// the loop.
|
|
406
|
+
// A sticky regex resets lastIndex to 0 when exec() returns null, so track how
|
|
407
|
+
// far we parsed instead of reading lastIndex after the loop.
|
|
486
408
|
consumed = re.lastIndex
|
|
487
409
|
if (m[1] !== undefined) {
|
|
488
410
|
if (vars[m[1]] === undefined) return null
|
|
@@ -492,18 +414,14 @@ function evalPathExpr(expr, vars) {
|
|
|
492
414
|
} else if (m[3] !== undefined) {
|
|
493
415
|
out += m[3]
|
|
494
416
|
}
|
|
495
|
-
// '.' (concatenation) and whitespace contribute nothing
|
|
496
417
|
}
|
|
497
418
|
if (consumed !== s.length) return null
|
|
498
419
|
return out
|
|
499
420
|
}
|
|
500
421
|
|
|
501
|
-
// Parse
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// 'Fully\\Qualified\\Class' => $baseDir . '/X.php' // multi=false (classmap/files)
|
|
505
|
-
// Returns a Map of key -> (dirs[] when multi, file when not). PHP single-quote
|
|
506
|
-
// escapes (`\\`, `\'`) in keys are unfolded to real characters.
|
|
422
|
+
// Parse a Composer generated `autoload_*.php` map (one entry per line). `multi`
|
|
423
|
+
// true -> psr-4/psr-0 (key -> dirs[]); false -> classmap/files (key -> file).
|
|
424
|
+
// PHP single-quote escapes in keys are unfolded.
|
|
507
425
|
function parseGeneratedMap(content, vars, multi) {
|
|
508
426
|
const out = new Map()
|
|
509
427
|
for (const line of content.split('\n')) {
|
|
@@ -535,24 +453,18 @@ function readJsonIfExists(file) {
|
|
|
535
453
|
}
|
|
536
454
|
}
|
|
537
455
|
|
|
538
|
-
// Build the autoload
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
// - the generated `vendor/composer/autoload_{psr4,namespaces,classmap,files}.php`
|
|
543
|
-
// maps (cover the root AND every installed dependency; the authoritative
|
|
544
|
-
// source once `composer install`/`dump-autoload` has run)
|
|
545
|
-
// All directory/file paths are normalised to POSIX baseDir-relative; anything
|
|
546
|
-
// escaping the root is dropped (it can't be bundled). Returns
|
|
547
|
-
// `{ psr4, psr0, classmap, files }` or null when no Composer config is found.
|
|
456
|
+
// Build the autoload config for `baseDir`, merging the root composer.json
|
|
457
|
+
// `autoload`(+dev) with the generated `vendor/composer/autoload_*` maps. All
|
|
458
|
+
// paths normalised to POSIX baseDir-relative (escapers dropped). Returns
|
|
459
|
+
// `{ psr4, psr0, classmap, files }`, or null when no Composer config is found.
|
|
548
460
|
export function loadComposerAutoload(baseDir) {
|
|
549
461
|
const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
|
|
550
462
|
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
551
463
|
|
|
552
|
-
const psr4 = new Map()
|
|
553
|
-
const psr0 = new Map()
|
|
554
|
-
const classmap = new Map()
|
|
555
|
-
const files = new Set()
|
|
464
|
+
const psr4 = new Map()
|
|
465
|
+
const psr0 = new Map()
|
|
466
|
+
const classmap = new Map()
|
|
467
|
+
const files = new Set()
|
|
556
468
|
let found = false
|
|
557
469
|
|
|
558
470
|
const addPrefixDirs = (map, prefix, paths) => {
|
|
@@ -618,11 +530,9 @@ export function loadComposerAutoload(baseDir) {
|
|
|
618
530
|
return { psr4, psr0, classmap, files: [...files] }
|
|
619
531
|
}
|
|
620
532
|
|
|
621
|
-
// Resolve a
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
// PSR-0 prefix. Only returns a path that actually exists on disk (so a PSR-4
|
|
625
|
-
// prefix match for a class whose file is absent doesn't get bundled as a hole).
|
|
533
|
+
// Resolve a FQCN to a baseDir-relative file via the autoload config, mirroring
|
|
534
|
+
// Composer's lookup order: classmap, then longest PSR-4 prefix, then PSR-0. Only
|
|
535
|
+
// returns a path that exists on disk (an absent prefix match isn't bundled as a hole).
|
|
626
536
|
export function resolveClassFile(fqcn, autoload, baseDir) {
|
|
627
537
|
if (!autoload || !fqcn) return null
|
|
628
538
|
const name = fqcn.replace(/^\\+/u, '')
|
|
@@ -657,20 +567,15 @@ export function resolveClassFile(fqcn, autoload, baseDir) {
|
|
|
657
567
|
return null
|
|
658
568
|
}
|
|
659
569
|
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// back to shorter ones (and the `""` fallback) when a file isn't present.
|
|
570
|
+
// [prefix, dirs] entries whose prefix is a prefix of `name`, longest first --
|
|
571
|
+
// prefer the most specific mapping, fall back to shorter (and the `""` fallback).
|
|
663
572
|
function matchingPrefixes(map, name) {
|
|
664
573
|
return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
|
|
665
574
|
}
|
|
666
575
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
// authoritative source, since a package's own composer.json usually omits
|
|
671
|
-
// `version` (Composer derives it at install time). Handles both the Composer 2
|
|
672
|
-
// shape ({ packages: [...] }) and the Composer 1 flat array. Returns maps from
|
|
673
|
-
// install dir (baseDir-relative) and from package name to version.
|
|
576
|
+
// Read installed-package versions from `vendor/composer/installed.json` (the
|
|
577
|
+
// authoritative source; a package's own composer.json usually omits `version`).
|
|
578
|
+
// Handles the Composer 2 ({ packages }) and Composer 1 (flat array) shapes.
|
|
674
579
|
function loadInstalledVersions(baseDir, vendorDir) {
|
|
675
580
|
const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
676
581
|
const byDir = new Map()
|
|
@@ -689,11 +594,9 @@ function loadInstalledVersions(baseDir, vendorDir) {
|
|
|
689
594
|
return { byDir, byName }
|
|
690
595
|
}
|
|
691
596
|
|
|
692
|
-
// Walk up from a bundled file to the nearest composer.json
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
// falling back to composer.json's own `version`, then `fallbackVersion`.
|
|
696
|
-
// Returns null when no named composer.json sits at or above the file.
|
|
597
|
+
// Walk up from a bundled file to the nearest named composer.json, returning
|
|
598
|
+
// { pkgDir, name, version } (pkgDir "." for the root; version from installed.json
|
|
599
|
+
// by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
697
600
|
function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
698
601
|
let dir = dirname(fileRelPath)
|
|
699
602
|
for (;;) {
|
|
@@ -709,13 +612,10 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
|
709
612
|
}
|
|
710
613
|
}
|
|
711
614
|
|
|
712
|
-
// Group bundled PHP sources into per-package buckets keyed by the
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
// no named composer.json above them (e.g. Composer's generated autoloader glue,
|
|
717
|
-
// or a project without a composer.json) fall back to "." with the given
|
|
718
|
-
// placeholder identity. Returns a Map<dir, { name, version, files }>.
|
|
615
|
+
// Group bundled PHP sources into per-package buckets keyed by the nearest
|
|
616
|
+
// composer.json's directory (vendor deps get their own; workspace files and
|
|
617
|
+
// orphans -> the root "." bucket with the placeholder identity). Returns a
|
|
618
|
+
// Map<dir, { name, version, files }>.
|
|
719
619
|
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
|
|
720
620
|
const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
|
|
721
621
|
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
@@ -735,9 +635,8 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
735
635
|
const pkg = findComposerPackage(baseDir, path, installed, fallbackVersion)
|
|
736
636
|
if (pkg) {
|
|
737
637
|
const rel = pkg.pkgDir === '.' ? path : path.slice(pkg.pkgDir.length + 1)
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// bucket is the workspace's own code and stays ecosystem-less.
|
|
638
|
+
// Below the root package = an installed Composer dependency, tagged
|
|
639
|
+
// `composer`; the root "." bucket is workspace code and stays ecosystem-less.
|
|
741
640
|
const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
|
|
742
641
|
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
|
|
743
642
|
} else {
|
|
@@ -747,16 +646,10 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
747
646
|
return modules
|
|
748
647
|
}
|
|
749
648
|
|
|
750
|
-
// Laravel auto-registers service providers
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
// providers do real work in boot() -- publishing config, loading routes/views,
|
|
755
|
-
// merging config -- referencing files (`config_path(...)`, `__DIR__ . '/...'`)
|
|
756
|
-
// that would otherwise never be reached. Returns the baseDir-relative files of
|
|
757
|
-
// every discoverable provider class (resolved via the autoload maps) plus
|
|
758
|
-
// bootstrap/providers.php when present, to seed as extra collection roots.
|
|
759
|
-
// Best-effort: unresolvable classes (and a missing autoload config) are skipped.
|
|
649
|
+
// Laravel auto-registers service providers nothing references statically. Returns
|
|
650
|
+
// discoverable provider files (from composer.json + installed.json
|
|
651
|
+
// `extra.laravel.providers`, autoload-resolved) plus bootstrap/providers.php as
|
|
652
|
+
// extra collection roots. Best-effort: unresolvable classes skipped.
|
|
760
653
|
export function loadLaravelProviderFiles(baseDir, autoload) {
|
|
761
654
|
const files = new Set()
|
|
762
655
|
if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
|
|
@@ -778,36 +671,28 @@ export function loadLaravelProviderFiles(baseDir, autoload) {
|
|
|
778
671
|
return [...files]
|
|
779
672
|
}
|
|
780
673
|
|
|
781
|
-
//
|
|
782
|
-
|
|
674
|
+
// Names excluded from class-reference scanning: pseudo-types, scalar/special
|
|
675
|
+
// types, and keywords that can sit before `$var`/`::` or after `new` and would
|
|
676
|
+
// otherwise be mistaken for a class reference (`return $x`, `new class`, …).
|
|
783
677
|
const RESERVED = new Set([
|
|
784
|
-
// pseudo-types / class-context keywords
|
|
785
678
|
'self', 'static', 'parent', 'this',
|
|
786
|
-
// scalar + special type names
|
|
787
679
|
'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean', 'void',
|
|
788
680
|
'array', 'callable', 'iterable', 'object', 'mixed', 'null', 'false', 'true',
|
|
789
681
|
'never', 'numeric',
|
|
790
|
-
// statement / expression keywords that can sit before a `$var`, after `new`,
|
|
791
|
-
// or before `::` and would otherwise be mistaken for a class reference
|
|
792
|
-
// (`return $x`, `echo $x`, `foreach (… as $x)`, `new class`, `match ($x)`, …)
|
|
793
682
|
'return', 'echo', 'print', 'clone', 'yield', 'throw', 'global', 'and', 'or', 'xor',
|
|
794
683
|
'as', 'else', 'elseif', 'do', 'class', 'fn', 'match', 'enddeclare', 'endforeach',
|
|
795
684
|
'endif', 'endswitch', 'endwhile', 'endfor',
|
|
796
685
|
])
|
|
797
686
|
|
|
798
|
-
// Blank
|
|
799
|
-
// reference scans don't trip over
|
|
800
|
-
// inside them. Unlike `maskCommentsAndHeredocs`, this also removes string
|
|
801
|
-
// contents -- class references never live in strings.
|
|
687
|
+
// Blank heredocs, comments AND string literals (unlike maskCommentsAndHeredocs,
|
|
688
|
+
// which keeps strings) so class-reference scans don't trip over text inside them.
|
|
802
689
|
function stripPhp(content) {
|
|
803
690
|
return maskNonCode(content, false)
|
|
804
691
|
}
|
|
805
692
|
|
|
806
|
-
// Add the FQCN for a class reference
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
// through it; otherwise an unqualified/qualified name is relative to the
|
|
810
|
-
// current namespace.
|
|
693
|
+
// Add the FQCN for a class reference `raw` given namespace `ns` and the `use`-alias
|
|
694
|
+
// map, mirroring PHP name resolution: leading `\` = fully-qualified; a first
|
|
695
|
+
// segment matching an alias expands through it; else relative to `ns`.
|
|
811
696
|
function addRef(set, raw, ns, aliasMap) {
|
|
812
697
|
const n = raw.trim().replace(/^\?/u, '') // drop a nullable-type marker (`?Foo`)
|
|
813
698
|
if (!n || !/^\\?[A-Za-z_][\w\\]*$/u.test(n)) return
|
|
@@ -827,10 +712,9 @@ function addRef(set, raw, ns, aliasMap) {
|
|
|
827
712
|
}
|
|
828
713
|
}
|
|
829
714
|
|
|
830
|
-
// Parse
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
// the symbol as written (sans alias) and `short` is the alias or last segment.
|
|
715
|
+
// Parse a `use` body (between `use` and `;`) into entries, handling comma lists
|
|
716
|
+
// and group syntax (`Pre\{A, B as C}`). Returns `[{ name, short }]` (short = the
|
|
717
|
+
// alias or last segment).
|
|
834
718
|
function parseUseBody(body) {
|
|
835
719
|
const grouped = body.match(/^([\w\\]+\\)\{([\s\S]*)\}$/u)
|
|
836
720
|
const entries = grouped
|
|
@@ -846,34 +730,27 @@ function parseUseBody(body) {
|
|
|
846
730
|
return out
|
|
847
731
|
}
|
|
848
732
|
|
|
849
|
-
// Position of the first class/interface/trait/enum declaration
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
// does load the trait). `::class` and member accesses are excluded via the
|
|
854
|
-
// lookbehind. Returns Infinity for a file with no class-like declaration.
|
|
855
|
-
// Cheaper and more robust than brace-matching -- no nested-brace bookkeeping,
|
|
856
|
-
// and heredocs are already stripped out.
|
|
733
|
+
// Position of the first class/interface/trait/enum declaration -- the end of the
|
|
734
|
+
// import region (a `use` before it is an alias import; after it, an in-body trait
|
|
735
|
+
// use that loads the trait). `::class`/member accesses excluded via the
|
|
736
|
+
// lookbehind. Infinity when there's no class-like declaration.
|
|
857
737
|
function firstDeclPos(stripped) {
|
|
858
738
|
const m = stripped.match(/(?<![:\w$\\>])\b(?:class|interface|trait|enum)\b/iu)
|
|
859
739
|
return m ? m.index : Infinity
|
|
860
740
|
}
|
|
861
741
|
|
|
862
|
-
// Collect the
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// / parameter+return type hints / fully-qualified names, plus in-body
|
|
867
|
-
// `use Trait;` (which does load the trait).
|
|
742
|
+
// Collect the FQCNs a PHP file actually depends on. A `use` import only aliases
|
|
743
|
+
// and loads nothing, so it is NOT a dependency; only real reference sites pull a
|
|
744
|
+
// file in (new/extends/implements/instanceof/catch/`Foo::bar`/attributes/type
|
|
745
|
+
// hints/FQNs, plus in-body `use Trait;`).
|
|
868
746
|
export function phpClassDependencies(content) {
|
|
869
747
|
const stripped = stripPhp(content)
|
|
870
748
|
const nsMatch = stripped.match(/\bnamespace\s+([\w\\]+)/u)
|
|
871
749
|
const ns = nsMatch ? nsMatch[1].replace(/^\\+|\\+$/gu, '') : ''
|
|
872
750
|
const set = new Set()
|
|
873
751
|
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
// count as references.
|
|
752
|
+
// `use` before the first declaration builds the alias map; a `use` after it is
|
|
753
|
+
// an in-body trait use, counted as a reference.
|
|
877
754
|
const declAt = firstDeclPos(stripped)
|
|
878
755
|
const aliasMap = new Map()
|
|
879
756
|
const traitRefs = []
|
|
@@ -904,20 +781,16 @@ export function phpClassDependencies(content) {
|
|
|
904
781
|
if (impl) for (const nm of impl[1].split(',')) add(nm)
|
|
905
782
|
}
|
|
906
783
|
|
|
907
|
-
// catch (A | B $e): capture the type list up to
|
|
908
|
-
//
|
|
909
|
-
// long blanked run sits inside the parens with no `$var`.
|
|
784
|
+
// catch (A | B $e): capture the type list up to `$var`. The lazy `[^)]*?` (no
|
|
785
|
+
// second whitespace quantifier) keeps matching linear on adversarial input.
|
|
910
786
|
for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
|
|
911
787
|
for (const nm of m[1].split('|')) add(nm)
|
|
912
788
|
}
|
|
913
789
|
|
|
914
|
-
// parameter
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
// quantifier is unbounded (the inner `\s*` is gated behind a required `[&.]`),
|
|
919
|
-
// which avoids catastrophic backtracking when a token is followed by a long
|
|
920
|
-
// blanked run (comment/heredoc/string) with no `$var` after it.
|
|
790
|
+
// parameter/property type hints: `Type $var`, incl. `?Type`, variadic, by-ref,
|
|
791
|
+
// and union/intersection `A|B`/`A&B` (each member counts). Only one whitespace
|
|
792
|
+
// quantifier is unbounded (inner `\s*` gated behind `[&.]`) to avoid
|
|
793
|
+
// catastrophic backtracking against adversarial input.
|
|
921
794
|
for (const m of stripped.matchAll(/(?<![\w$\\])([?\w\\|&]+)\s+(?:[&.]+\s*)?\$\w+/giu)) {
|
|
922
795
|
for (const nm of m[1].split(/[|&]/u)) add(nm)
|
|
923
796
|
}
|
|
@@ -929,11 +802,8 @@ export function phpClassDependencies(content) {
|
|
|
929
802
|
return set
|
|
930
803
|
}
|
|
931
804
|
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
// Resolve every autoloaded class a file references to a baseDir-relative file
|
|
935
|
-
// path that exists on disk and is already part of `sources`. Best-effort:
|
|
936
|
-
// unresolvable references (builtins, extensions, dynamic) are skipped.
|
|
805
|
+
// Resolve each autoloaded class a file references to a baseDir-relative file that
|
|
806
|
+
// exists and is in `sources`. Best-effort: unresolvable refs skipped.
|
|
937
807
|
function autoloadEdges(content, autoload, baseDir, sources) {
|
|
938
808
|
const edges = new Map()
|
|
939
809
|
if (!autoload) return edges
|
|
@@ -944,12 +814,10 @@ function autoloadEdges(content, autoload, baseDir, sources) {
|
|
|
944
814
|
return edges
|
|
945
815
|
}
|
|
946
816
|
|
|
947
|
-
// Build
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
// `
|
|
951
|
-
// `missing`. As a final include fallback, a specifier that matches a stored
|
|
952
|
-
// key verbatim is accepted.
|
|
817
|
+
// Build `{ sources, resolutions, missing }` from already-loaded PHP sources.
|
|
818
|
+
// Explicit includes are strict (unresolvable -> `missing`); Composer-autoloaded
|
|
819
|
+
// class refs (when `autoload` is given) are best-effort and never added to
|
|
820
|
+
// `missing`. Fallback: a specifier matching a stored key verbatim is accepted.
|
|
953
821
|
export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
|
|
954
822
|
const resolutions = new Map()
|
|
955
823
|
const missing = []
|
|
@@ -974,10 +842,9 @@ export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
|
|
|
974
842
|
return { sources, resolutions, missing }
|
|
975
843
|
}
|
|
976
844
|
|
|
977
|
-
// Walk the filesystem
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
// in parallel; the next wave depends on what the previous one referenced.
|
|
845
|
+
// Walk the filesystem from `entries` (plus Composer `files` autoload entries),
|
|
846
|
+
// following resolved includes and autoloaded class refs, reading each file once.
|
|
847
|
+
// Same-wave files are read in parallel; the next wave depends on the previous.
|
|
981
848
|
export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = null } = {}) {
|
|
982
849
|
const sources = new Map()
|
|
983
850
|
const realBase = realpathSync(baseDir)
|
|
@@ -987,10 +854,9 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
987
854
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
988
855
|
return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
|
|
989
856
|
} catch (err) {
|
|
990
|
-
// ENOENT
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// unresolved edge is surfaced later by buildPhpTree for real includes.
|
|
857
|
+
// ENOENT (no file) / EISDIR (specifier resolved to a directory): not a
|
|
858
|
+
// loadable file -- warn and skip rather than crash (buildPhpTree surfaces
|
|
859
|
+
// the unresolved edge for real includes).
|
|
994
860
|
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
|
|
995
861
|
console.warn(`[loader.php] Missing import: ${relPath}`)
|
|
996
862
|
return null
|
|
@@ -1002,9 +868,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1002
868
|
const processWave = async (wave) => {
|
|
1003
869
|
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
1004
870
|
if (toLoad.length === 0) return
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
// Promise.all would open every file at once and crash with EMFILE.
|
|
871
|
+
// Bounded batches: a wave can be huge (a dynamic include's directory glob),
|
|
872
|
+
// and an unbounded Promise.all would open every file at once and crash EMFILE.
|
|
1008
873
|
const reads = []
|
|
1009
874
|
for (let k = 0; k < toLoad.length; k += READ_CONCURRENCY) {
|
|
1010
875
|
// eslint-disable-next-line no-await-in-loop -- batches are intentionally sequential to cap open files
|
|
@@ -1023,9 +888,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1023
888
|
console.warn(`[loader.php] Missing import: ${spec} from ${relPath}`)
|
|
1024
889
|
}
|
|
1025
890
|
}
|
|
1026
|
-
// Dynamic includes with a static
|
|
1027
|
-
//
|
|
1028
|
-
// runtime, so bundle every `.php` file in that directory as a candidate.
|
|
891
|
+
// Dynamic includes with a static dir prefix: the exact file is known only
|
|
892
|
+
// at runtime, so bundle every `.php` file in that directory as a candidate.
|
|
1029
893
|
for (const dirSpec of extractPhpImportDirs(content)) {
|
|
1030
894
|
const dir = resolvePhpDir(dirSpec, relPath, baseDir)
|
|
1031
895
|
if (!dir) continue
|
|
@@ -1033,10 +897,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1033
897
|
if (!sources.has(file)) next.push(file)
|
|
1034
898
|
}
|
|
1035
899
|
}
|
|
1036
|
-
// `.php` path literals
|
|
1037
|
-
//
|
|
1038
|
-
// helper refs (`base_path(...)`) against the project root. Best-effort:
|
|
1039
|
-
// bundle only if they resolve to a file that exists.
|
|
900
|
+
// `.php` path literals outside a require (route/config files passed to a
|
|
901
|
+
// framework). Best-effort: bundle only if they resolve to an existing file.
|
|
1040
902
|
for (const { spec, anchor } of extractPhpPathRefs(content)) {
|
|
1041
903
|
const resolved = anchor === 'root'
|
|
1042
904
|
? normalizeProjectRel(baseDir, spec)
|
|
@@ -1051,8 +913,7 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1051
913
|
await processWave(next)
|
|
1052
914
|
}
|
|
1053
915
|
|
|
1054
|
-
// `files` autoload entries
|
|
1055
|
-
// are roots of the graph alongside the caller's entries.
|
|
916
|
+
// Composer `files` autoload entries load unconditionally, so they're graph roots too.
|
|
1056
917
|
await processWave([...entries, ...(autoload?.files ?? [])])
|
|
1057
918
|
return sources
|
|
1058
919
|
}
|