@exodus/stasis 1.0.0-alpha.2 → 1.0.0-beta.0

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.
@@ -0,0 +1,1058 @@
1
+ // PHP include-graph + Composer-autoload loader, modelled on the Solidity loader
2
+ // in this directory.
3
+ //
4
+ // Produces a `{ sources, resolutions, missing }` triple by statically scanning
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.
34
+
35
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
36
+ import { readFile } from 'node:fs/promises'
37
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
38
+
39
+ import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
40
+
41
+ // Max files read concurrently per wave, to avoid EMFILE on large directory globs.
42
+ const READ_CONCURRENCY = 64
43
+
44
+ // --- Shared lexing helpers ---------------------------------------------------
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.
50
+ function heredocEnd(code, start) {
51
+ const n = code.length
52
+ let j = start + 3
53
+ while (j < n && (code[j] === ' ' || code[j] === '\t')) j++
54
+ const quote = code[j] === '"' || code[j] === "'" ? code[j++] : ''
55
+ let label = ''
56
+ while (j < n && /\w/u.test(code[j])) label += code[j++]
57
+ if (label === '') return start
58
+ if (quote && code[j] === quote) j++
59
+ let p = code.indexOf('\n', j)
60
+ if (p === -1) return n
61
+ p += 1
62
+ while (p <= n) {
63
+ let q = p
64
+ while (q < n && (code[q] === ' ' || code[q] === '\t')) q++
65
+ if (code.startsWith(label, q)) {
66
+ const after = code[q + label.length]
67
+ if (after === undefined || !/\w/u.test(after)) {
68
+ const nl = code.indexOf('\n', q)
69
+ return nl === -1 ? n : nl
70
+ }
71
+ }
72
+ const nl = code.indexOf('\n', p)
73
+ if (nl === -1) return n
74
+ p = nl + 1
75
+ }
76
+ return n
77
+ }
78
+
79
+ // Single linear pass that blanks everything which is not executable PHP code:
80
+ // `//`/`#` line comments, `/* */` block comments, heredocs/nowdocs, and (unless
81
+ // `keepStrings`) string literals. Each consumed character becomes a space
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.
88
+ function maskNonCode(code, keepStrings) {
89
+ const n = code.length
90
+ const blank = (s) => s.replace(/[^\n]/gu, ' ')
91
+ let out = ''
92
+ let i = 0
93
+ while (i < n) {
94
+ const c = code[i]
95
+ if ((c === '/' && code[i + 1] === '/') || (c === '#' && code[i + 1] !== '[')) {
96
+ let end = code.indexOf('\n', i)
97
+ if (end === -1) end = n
98
+ out += blank(code.slice(i, end))
99
+ i = end
100
+ } else if (c === '/' && code[i + 1] === '*') {
101
+ const close = code.indexOf('*/', i + 2)
102
+ const end = close === -1 ? n : close + 2
103
+ out += blank(code.slice(i, end))
104
+ i = end
105
+ } else if (c === '<' && code[i + 1] === '<' && code[i + 2] === '<') {
106
+ const end = heredocEnd(code, i)
107
+ if (end === i) { out += c; i++ } else { out += blank(code.slice(i, end)); i = end }
108
+ } else if (c === "'" || c === '"') {
109
+ let j = i + 1
110
+ while (j < n) {
111
+ if (code[j] === '\\') { j += 2; continue }
112
+ if (code[j] === c) { j++; break }
113
+ j++
114
+ }
115
+ out += keepStrings ? code.slice(i, j) : blank(code.slice(i, j))
116
+ i = j
117
+ } else {
118
+ out += c
119
+ i++
120
+ }
121
+ }
122
+ return out
123
+ }
124
+
125
+ // Comments/heredocs blanked, string literals kept (include paths live in
126
+ // strings). See maskNonCode.
127
+ function maskCommentsAndHeredocs(content) {
128
+ return maskNonCode(content, true)
129
+ }
130
+
131
+ // Scan code for string literals, returning each as { start, end, value } with
132
+ // PHP single-quote-style unescaping (only `\\` and the closing quote are
133
+ // escapes; every other backslash is literal -- good enough for path values).
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.
137
+ function stringLiterals(code) {
138
+ const out = []
139
+ let i = 0
140
+ while (i < code.length) {
141
+ const q = code[i]
142
+ if (q === "'" || q === '"') {
143
+ let j = i + 1
144
+ let value = ''
145
+ while (j < code.length) {
146
+ const c = code[j]
147
+ if (c === '\\') {
148
+ const next = code[j + 1]
149
+ value += next === '\\' || next === q ? next : c + (next ?? '')
150
+ j += next === undefined ? 1 : 2
151
+ continue
152
+ }
153
+ if (c === q) { j++; break }
154
+ value += c
155
+ j++
156
+ }
157
+ out.push({ start: i, end: j, value, quote: q })
158
+ i = j
159
+ } else {
160
+ i++
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
166
+ // --- Explicit includes -------------------------------------------------------
167
+
168
+ // Locates an include keyword. The leading lookbehind keeps method calls
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.
173
+ const PHP_INCLUDE_KEYWORD_RE = /(?<![\w$>:])(?:require_once|require|include_once|include)\b/gidu
174
+
175
+ // Directory anchor at a position (sticky): `__DIR__` or `dirname(__FILE__)`
176
+ // (both = the file's own directory), or `dirname(__DIR__[, N])` (N parent levels
177
+ // up -- the idiom for locating bootstrap/vendor/config relative to a file).
178
+ const PHP_DIR_ANCHOR_RE = /__DIR__\b|dirname\s*\(\s*(__FILE__|__DIR__)\s*(?:,\s*(\d+)\s*)?\)/y
179
+ // Anchor starts, for scanning path literals anywhere (not just after `require`).
180
+ const PHP_DIR_ANCHOR_FIND_RE = /(?<![\w$>])(?:__DIR__\b|dirname\s*\(\s*(?:__FILE__|__DIR__))/gu
181
+
182
+ // Laravel path helpers that resolve a string argument relative to the project
183
+ // root (or a fixed subdirectory). `base_path('routes/web.php')`,
184
+ // `config_path('app.php')`, … name real PHP files that the framework require()s.
185
+ const PHP_PATH_HELPERS = new Map([
186
+ ['base_path', ''],
187
+ ['app_path', 'app'],
188
+ ['config_path', 'config'],
189
+ ['database_path', 'database'],
190
+ ['lang_path', 'lang'],
191
+ ['public_path', 'public'],
192
+ ['resource_path', 'resources'],
193
+ ['storage_path', 'storage'],
194
+ ])
195
+ const PHP_PATH_HELPER_FIND_RE =
196
+ /(?<![\w$>:])(base_path|app_path|config_path|database_path|lang_path|public_path|resource_path|storage_path)\s*\(/giu
197
+
198
+ // Interpolation markers inside a double-quoted string (`$var`, `${...}`,
199
+ // `{$...}`). Single-quoted strings never interpolate, so a `$` there is literal.
200
+ const PHP_INTERPOLATION_RE = /\$[A-Za-z_{]|\{\$/u
201
+
202
+ function skipWs(code, pos) {
203
+ while (pos < code.length && /\s/u.test(code[pos])) pos++
204
+ return pos
205
+ }
206
+
207
+ // If a directory anchor starts at `pos`, return { end, up } (up = parent levels
208
+ // above the file's directory: 0 for `__DIR__`/`dirname(__FILE__)`, N for
209
+ // `dirname(__DIR__[, N])`); otherwise null.
210
+ function matchDirAnchor(code, pos) {
211
+ PHP_DIR_ANCHOR_RE.lastIndex = pos
212
+ const m = PHP_DIR_ANCHOR_RE.exec(code)
213
+ if (!m) return null
214
+ const up = m[1] === '__DIR__' ? (m[2] ? Number(m[2]) : 1) : 0
215
+ return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
216
+ }
217
+
218
+ // Normalise an include path to a specifier. Dir-relative paths (`__DIR__ . …`)
219
+ // get a `./` prefix and have the leading separator stripped; an empty
220
+ // dir-relative path means "the file's own directory" (`.`).
221
+ function normalizeIncludePath(raw, dirRelative) {
222
+ if (!dirRelative) return raw
223
+ const rel = raw.replace(/^\/+/u, '')
224
+ if (rel === '') return '.'
225
+ return rel.startsWith('.') ? rel : `./${rel}`
226
+ }
227
+
228
+ // Parse an include/path argument starting at `start`: an optional directory
229
+ // anchor (`__DIR__`/`dirname(...)`), then a concatenation chain of string
230
+ // literals. Consecutive *static* literals are concatenated (`__DIR__ . '/a' .
231
+ // '/b.php'` -> one static path); the chain becomes `dynamic` at the first
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.
236
+ function parseArgPath(code, start, strings) {
237
+ const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
238
+ let i = start
239
+ let dirRelative = false
240
+ let up = 0
241
+ const anchor = matchDirAnchor(code, i)
242
+ if (anchor) {
243
+ i = skipWs(code, anchor.end)
244
+ dirRelative = true
245
+ up = anchor.up
246
+ if (code[i] !== '.') return null // anchor not concatenated with a path
247
+ i = skipWs(code, i + 1)
248
+ }
249
+
250
+ let prefix = '/..'.repeat(up)
251
+ let dynamic = false
252
+ let sawStatic = false
253
+ for (;;) {
254
+ const lit = stringAt(i)
255
+ if (!lit) { dynamic = true; break } // variable/constant/function -> dynamic
256
+ const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
257
+ if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
258
+ prefix += lit.value
259
+ sawStatic = true
260
+ i = skipWs(code, lit.end)
261
+ if (code[i] === '.') { i = skipWs(code, i + 1); continue } // concatenated -> keep going
262
+ break
263
+ }
264
+ if (!dirRelative && !sawStatic) return null
265
+ return { dirRelative, up, prefix, dynamic, sawStatic }
266
+ }
267
+
268
+ // Scan PHP `require`/`include` statements, classifying each as a concrete file
269
+ // or -- when the path is built dynamically but has a static directory prefix --
270
+ // a directory whose `.php` files should all be bundled (any could be the runtime
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.
274
+ function scanIncludes(content) {
275
+ const code = maskCommentsAndHeredocs(content)
276
+ const strings = stringLiterals(code)
277
+ const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
278
+
279
+ const out = []
280
+ for (const m of code.matchAll(PHP_INCLUDE_KEYWORD_RE)) {
281
+ if (inString(m.index)) continue
282
+ let i = skipWs(code, m.index + m[0].length)
283
+ if (code[i] === '(') i = skipWs(code, i + 1)
284
+ const arg = parseArgPath(code, i, strings)
285
+ if (!arg) continue
286
+ const { dirRelative, up, prefix, dynamic, sawStatic } = arg
287
+
288
+ if (!dynamic) {
289
+ if (sawStatic) out.push({ kind: 'file', spec: normalizeIncludePath(prefix, dirRelative) })
290
+ continue
291
+ }
292
+ // Dynamic path: fall back to the static directory prefix (up to the last
293
+ // separator). A bare path with no separator has no usable directory.
294
+ if (!dirRelative && !prefix.includes('/')) continue
295
+ const lastSlash = prefix.lastIndexOf('/')
296
+ let dir = lastSlash >= 0 ? prefix.slice(0, lastSlash) : ''
297
+ if (dir === '' && up > 0) dir = '/..'.repeat(up) // `dirname(__DIR__) . $x` -> parent dir
298
+ out.push({ kind: 'dir', spec: normalizeIncludePath(dir, dirRelative) })
299
+ }
300
+ return out
301
+ }
302
+
303
+ // Dir-anchored static `.php` path literals anywhere in the file (not only after
304
+ // `require`/`include`). Frameworks pass file paths as arguments for code that
305
+ // `require`s them internally -- e.g. Laravel's `->withRouting(web: __DIR__ .
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.
309
+ function scanPathLiterals(content) {
310
+ const code = maskCommentsAndHeredocs(content)
311
+ const strings = stringLiterals(code)
312
+ const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
313
+ const stringAt = (pos) => strings.find(({ start }) => start === pos)
314
+
315
+ const out = []
316
+ // Dir-anchored literals: `__DIR__ . '/x.php'` (anchor: resolve against the file).
317
+ for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
318
+ if (inString(m.index)) continue
319
+ const arg = parseArgPath(code, m.index, strings)
320
+ if (!arg || arg.dynamic || !arg.sawStatic || !arg.dirRelative) continue
321
+ const spec = normalizeIncludePath(arg.prefix, true)
322
+ if (spec.endsWith('.php')) out.push({ spec, anchor: 'file' })
323
+ }
324
+ // Laravel path helpers: `base_path('routes/web.php')` etc. (anchor: resolve
325
+ // against the project root, with the helper's fixed subdirectory prefixed).
326
+ for (const m of code.matchAll(PHP_PATH_HELPER_FIND_RE)) {
327
+ if (inString(m.index)) continue
328
+ const lit = stringAt(skipWs(code, m.index + m[0].length))
329
+ if (!lit || (lit.quote === '"' && PHP_INTERPOLATION_RE.test(lit.value))) continue
330
+ const sub = PHP_PATH_HELPERS.get(m[1].toLowerCase())
331
+ const arg = lit.value.replace(/^\/+/u, '')
332
+ const spec = sub ? `${sub}/${arg}` : arg
333
+ if (spec.endsWith('.php')) out.push({ spec, anchor: 'root' })
334
+ }
335
+ return out
336
+ }
337
+
338
+ // Statically-resolvable include specifiers (concrete files). See scanIncludes.
339
+ export function extractPhpImports(content) {
340
+ return scanIncludes(content).filter((e) => e.kind === 'file').map((e) => e.spec)
341
+ }
342
+
343
+ // Directory specifiers for dynamic includes whose path has a static directory
344
+ // prefix; every `.php` file directly in such a directory is a candidate target.
345
+ export function extractPhpImportDirs(content) {
346
+ return scanIncludes(content).filter((e) => e.kind === 'dir').map((e) => e.spec)
347
+ }
348
+
349
+ // `.php` path literals used outside a `require`/`include` -- dir-anchored
350
+ // (`__DIR__ . '/x.php'`, `anchor: 'file'`) or via a Laravel path helper
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).
353
+ export function extractPhpPathRefs(content) {
354
+ const seen = new Set()
355
+ return scanPathLiterals(content).filter(({ spec, anchor }) => {
356
+ const key = `${anchor}:${spec}`
357
+ if (seen.has(key)) return false
358
+ seen.add(key)
359
+ return true
360
+ })
361
+ }
362
+
363
+ // Apply `segments` (already split on '/') on top of `fromFile`'s directory,
364
+ // collapsing '.'/'' and resolving '..'. Returns a baseDir-relative POSIX path,
365
+ // or null when traversal escapes above the project root (rather than clamping).
366
+ function applyToDir(fromFile, segments) {
367
+ const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
368
+ const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
369
+ const resolved = []
370
+ for (const part of parts) {
371
+ if (part === '.' || part === '') continue
372
+ if (part === '..') {
373
+ if (resolved.length === 0) return null
374
+ resolved.pop()
375
+ } else {
376
+ resolved.push(part)
377
+ }
378
+ }
379
+ return resolved.join('/')
380
+ }
381
+
382
+ function isFileOnDisk(baseDir, rel) {
383
+ try {
384
+ return statSync(join(baseDir, rel)).isFile()
385
+ } catch {
386
+ return false
387
+ }
388
+ }
389
+
390
+ function isDirOnDisk(baseDir, rel) {
391
+ try {
392
+ return statSync(join(baseDir, rel)).isDirectory()
393
+ } catch {
394
+ return false
395
+ }
396
+ }
397
+
398
+ // Resolve a directory specifier (from a dynamic include's static prefix) to an
399
+ // existing baseDir-relative directory, or null. `./`/`../` resolve against the
400
+ // including file; a bare spec tries file-relative then project-relative.
401
+ export function resolvePhpDir(spec, fromFile, baseDir) {
402
+ if (spec.startsWith('.')) {
403
+ const rel = applyToDir(fromFile, spec.split('/'))
404
+ return rel !== null && isDirOnDisk(baseDir, rel) ? rel : null
405
+ }
406
+ if (baseDir && !isAbsolute(spec)) {
407
+ const fileRel = applyToDir(fromFile, spec.split('/'))
408
+ if (fileRel && isDirOnDisk(baseDir, fileRel)) return fileRel
409
+ const projRel = relative(baseDir, resolve(baseDir, spec)).split(/[\\/]/u).join('/')
410
+ if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isDirOnDisk(baseDir, projRel)) {
411
+ return projRel
412
+ }
413
+ }
414
+ return null
415
+ }
416
+
417
+ // The `.php` files directly in `dir` (baseDir-relative), as baseDir-relative
418
+ // paths. Non-recursive: a dynamic include like `.../$view.php` targets a file in
419
+ // the directory itself, and recursing could pull in an unbounded subtree.
420
+ function listPhpFiles(baseDir, dir) {
421
+ try {
422
+ return readdirSync(join(baseDir, dir), { withFileTypes: true })
423
+ .filter((e) => e.isFile() && e.name.endsWith('.php'))
424
+ .map((e) => (dir === '.' ? e.name : `${dir}/${e.name}`))
425
+ } catch {
426
+ return []
427
+ }
428
+ }
429
+
430
+ // Resolve a PHP include specifier to a baseDir-relative POSIX path.
431
+ // - `./x` / `../x` resolve against the including file's directory, exactly
432
+ // like Solidity relative imports; escaping the root returns null.
433
+ // - a bare specifier (`lib/Foo.php`, `helpers.php`) is ambiguous under PHP's
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.
438
+ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
439
+ if (specifier.startsWith('.')) {
440
+ return applyToDir(fromFile, specifier.split('/'))
441
+ }
442
+
443
+ if (baseDir && !isAbsolute(specifier)) {
444
+ const fileRel = applyToDir(fromFile, specifier.split('/'))
445
+ if (fileRel && isFileOnDisk(baseDir, fileRel)) return fileRel
446
+
447
+ const projRel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
448
+ if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isFileOnDisk(baseDir, projRel)) {
449
+ return projRel
450
+ }
451
+ }
452
+
453
+ return null
454
+ }
455
+
456
+ // --- Composer autoload config ------------------------------------------------
457
+
458
+ // Normalise an autoload directory/file path that is relative to the project
459
+ // root to a POSIX baseDir-relative path, or null when it escapes the root.
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.
464
+ function normalizeProjectRel(baseDir, p) {
465
+ const rel = relative(baseDir, resolve(baseDir, p.replace(/\\/gu, '/'))).split(/[\\/]/u).join('/')
466
+ if (rel === '') return ''
467
+ if (rel.startsWith('..') || isAbsolute(rel)) return null
468
+ return rel
469
+ }
470
+
471
+ // Evaluate a generated-autoload path expression like `$baseDir . '/src'` or
472
+ // `$vendorDir . '/a/b'` or a bare quoted string, given the absolute values of
473
+ // the `$baseDir` / `$vendorDir` variables. Returns the absolute path string,
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.
476
+ function evalPathExpr(expr, vars) {
477
+ const s = expr.trim().replace(/,+$/u, '').trim()
478
+ let out = ''
479
+ let consumed = 0
480
+ const re = /\$(\w+)|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"|\.|\s+/gy
481
+ let m
482
+ while ((m = re.exec(s)) !== null) {
483
+ // A sticky regex resets lastIndex to 0 once exec() finally returns null,
484
+ // so track how far we actually parsed instead of reading lastIndex after
485
+ // the loop.
486
+ consumed = re.lastIndex
487
+ if (m[1] !== undefined) {
488
+ if (vars[m[1]] === undefined) return null
489
+ out += vars[m[1]]
490
+ } else if (m[2] !== undefined) {
491
+ out += m[2].replace(/\\(['\\])/gu, '$1')
492
+ } else if (m[3] !== undefined) {
493
+ out += m[3]
494
+ }
495
+ // '.' (concatenation) and whitespace contribute nothing
496
+ }
497
+ if (consumed !== s.length) return null
498
+ return out
499
+ }
500
+
501
+ // Parse one of Composer's generated `vendor/composer/autoload_*.php` maps.
502
+ // They are `@generated`, one entry per line:
503
+ // 'Prefix\\' => array($baseDir . '/src'), // multi=true (psr-4/psr-0)
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.
507
+ function parseGeneratedMap(content, vars, multi) {
508
+ const out = new Map()
509
+ for (const line of content.split('\n')) {
510
+ const m = line.match(/^\s*'((?:\\.|[^'\\])*)'\s*=>\s*(.+?),?\s*$/u)
511
+ if (!m) continue
512
+ const key = m[1].replace(/\\(['\\])/gu, '$1')
513
+ if (multi) {
514
+ const am = m[2].match(/^array\s*\(([\s\S]*)\)$/u)
515
+ if (!am) continue
516
+ const dirs = []
517
+ for (const piece of am[1].split(',')) {
518
+ const abs = evalPathExpr(piece, vars)
519
+ if (abs) dirs.push(abs)
520
+ }
521
+ out.set(key, dirs)
522
+ } else {
523
+ const abs = evalPathExpr(m[2], vars)
524
+ if (abs) out.set(key, abs)
525
+ }
526
+ }
527
+ return out
528
+ }
529
+
530
+ function readJsonIfExists(file) {
531
+ try {
532
+ return JSON.parse(readFileSync(file, 'utf8'))
533
+ } catch {
534
+ return null
535
+ }
536
+ }
537
+
538
+ // Build the autoload resolution config for a project rooted at `baseDir`.
539
+ // Merges, when present:
540
+ // - the root composer.json `autoload` + `autoload-dev` (psr-4/psr-0/files;
541
+ // paths are relative to the project root)
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.
548
+ export function loadComposerAutoload(baseDir) {
549
+ const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
550
+ const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
551
+
552
+ const psr4 = new Map() // prefix (trailing '\') -> dirs[]
553
+ const psr0 = new Map() // prefix -> dirs[]
554
+ const classmap = new Map() // FQCN -> file
555
+ const files = new Set() // baseDir-relative file
556
+ let found = false
557
+
558
+ const addPrefixDirs = (map, prefix, paths) => {
559
+ const list = Array.isArray(paths) ? paths : [paths]
560
+ const dirs = []
561
+ for (const p of list) {
562
+ const rel = normalizeProjectRel(baseDir, String(p))
563
+ if (rel !== null) dirs.push(rel)
564
+ }
565
+ const prev = map.get(prefix) ?? []
566
+ map.set(prefix, [...new Set([...prev, ...dirs])])
567
+ }
568
+
569
+ // Root composer.json autoload (+ dev). PSR-4 keys keep their trailing '\'.
570
+ for (const block of [composerJson?.autoload, composerJson?.['autoload-dev']]) {
571
+ if (!block || typeof block !== 'object') continue
572
+ found = true
573
+ for (const [prefix, paths] of Object.entries(block['psr-4'] ?? {})) addPrefixDirs(psr4, prefix, paths)
574
+ for (const [prefix, paths] of Object.entries(block['psr-0'] ?? {})) addPrefixDirs(psr0, prefix, paths)
575
+ for (const f of block.files ?? []) {
576
+ const rel = normalizeProjectRel(baseDir, String(f))
577
+ if (rel) files.add(rel)
578
+ }
579
+ }
580
+
581
+ // Generated maps. `$vendorDir` = <baseDir>/<vendorDir>, `$baseDir` = <baseDir>.
582
+ const composerGenDir = join(baseDir, vendorDir, 'composer')
583
+ const vars = { baseDir: resolve(baseDir), vendorDir: resolve(baseDir, vendorDir) }
584
+ const toRel = (abs) => {
585
+ const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
586
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) ? rel : null
587
+ }
588
+ const mergePrefixMap = (file, target) => {
589
+ if (!existsSync(file)) return
590
+ found = true
591
+ for (const [prefix, dirs] of parseGeneratedMap(readFileSync(file, 'utf8'), vars, true)) {
592
+ const rels = dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, ''))
593
+ const prev = target.get(prefix) ?? []
594
+ target.set(prefix, [...new Set([...prev, ...rels])])
595
+ }
596
+ }
597
+ mergePrefixMap(join(composerGenDir, 'autoload_psr4.php'), psr4)
598
+ mergePrefixMap(join(composerGenDir, 'autoload_namespaces.php'), psr0)
599
+
600
+ const classmapFile = join(composerGenDir, 'autoload_classmap.php')
601
+ if (existsSync(classmapFile)) {
602
+ found = true
603
+ for (const [fqcn, abs] of parseGeneratedMap(readFileSync(classmapFile, 'utf8'), vars, false)) {
604
+ const rel = toRel(abs)
605
+ if (rel) classmap.set(fqcn.replace(/^\\+/u, ''), rel)
606
+ }
607
+ }
608
+ const filesFile = join(composerGenDir, 'autoload_files.php')
609
+ if (existsSync(filesFile)) {
610
+ found = true
611
+ for (const [, abs] of parseGeneratedMap(readFileSync(filesFile, 'utf8'), vars, false)) {
612
+ const rel = toRel(abs)
613
+ if (rel) files.add(rel)
614
+ }
615
+ }
616
+
617
+ if (!found) return null
618
+ return { psr4, psr0, classmap, files: [...files] }
619
+ }
620
+
621
+ // Resolve a fully-qualified class name to a baseDir-relative file via the
622
+ // autoload config, mirroring Composer's ClassLoader lookup order: exact
623
+ // classmap hit, then longest-matching PSR-4 prefix, then longest-matching
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).
626
+ export function resolveClassFile(fqcn, autoload, baseDir) {
627
+ if (!autoload || !fqcn) return null
628
+ const name = fqcn.replace(/^\\+/u, '')
629
+ if (!name) return null
630
+
631
+ const exact = autoload.classmap.get(name)
632
+ if (exact && isFileOnDisk(baseDir, exact)) return exact
633
+
634
+ // PSR-4: try every matching prefix longest-first (Composer falls back from a
635
+ // longer prefix whose file is absent to a shorter one, incl. the `""` prefix).
636
+ for (const [prefix, dirs] of matchingPrefixes(autoload.psr4, name)) {
637
+ const sub = name.slice(prefix.length).replace(/\\/gu, '/')
638
+ for (const dir of dirs) {
639
+ const file = dir ? `${dir}/${sub}.php` : `${sub}.php`
640
+ if (isFileOnDisk(baseDir, file)) return file
641
+ }
642
+ }
643
+
644
+ // PSR-0: namespace separators AND underscores in the class name map to
645
+ // directory separators; the namespace prefix is kept under the mapped dir.
646
+ for (const [, dirs] of matchingPrefixes(autoload.psr0, name)) {
647
+ const lastNs = name.lastIndexOf('\\')
648
+ const nsPart = lastNs === -1 ? '' : name.slice(0, lastNs).replace(/\\/gu, '/')
649
+ const clsPart = (lastNs === -1 ? name : name.slice(lastNs + 1)).replace(/_/gu, '/')
650
+ const sub = `${nsPart ? `${nsPart}/` : ''}${clsPart}.php`
651
+ for (const dir of dirs) {
652
+ const file = dir ? `${dir}/${sub}` : sub
653
+ if (isFileOnDisk(baseDir, file)) return file
654
+ }
655
+ }
656
+
657
+ return null
658
+ }
659
+
660
+ // All [prefix, dirs] entries of a PSR map whose prefix is a prefix of `name`,
661
+ // longest first -- so resolution prefers the most specific mapping but falls
662
+ // back to shorter ones (and the `""` fallback) when a file isn't present.
663
+ function matchingPrefixes(map, name) {
664
+ return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
665
+ }
666
+
667
+ // --- Per-package bucketing ---------------------------------------------------
668
+
669
+ // Read installed-package versions from `vendor/composer/installed.json` -- the
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.
674
+ function loadInstalledVersions(baseDir, vendorDir) {
675
+ const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
676
+ const byDir = new Map()
677
+ const byName = new Map()
678
+ const packages = Array.isArray(json) ? json : (json?.packages ?? [])
679
+ for (const p of packages) {
680
+ if (!p?.name || !p.version) continue
681
+ byName.set(p.name, p.version)
682
+ // install-path is relative to vendor/composer; default layout is vendor/<name>.
683
+ const abs = p['install-path']
684
+ ? resolve(baseDir, vendorDir, 'composer', p['install-path'])
685
+ : resolve(baseDir, vendorDir, p.name)
686
+ const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
687
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) byDir.set(rel, p.version)
688
+ }
689
+ return { byDir, byName }
690
+ }
691
+
692
+ // Walk up from a bundled file to the nearest composer.json with a `name`,
693
+ // returning { pkgDir, name, version } (pkgDir baseDir-relative, "." for the
694
+ // root package). Version comes from installed.json (by dir, then by name),
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.
697
+ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
698
+ let dir = dirname(fileRelPath)
699
+ for (;;) {
700
+ const cj = readJsonIfExists(join(baseDir, dir, 'composer.json'))
701
+ if (cj?.name) {
702
+ const version = installed.byDir.get(dir) ?? installed.byName.get(cj.name) ?? cj.version ?? fallbackVersion
703
+ return { pkgDir: dir, name: cj.name, version }
704
+ }
705
+ if (dir === '.' || dir === '/' || dir === '') return null
706
+ const parent = dirname(dir)
707
+ if (parent === dir) return null
708
+ dir = parent
709
+ }
710
+ }
711
+
712
+ // Group bundled PHP sources into per-package buckets keyed by the directory of
713
+ // the nearest composer.json: vendor dependencies land in their own
714
+ // `vendor/<vendor>/<pkg>` bucket (name + version from composer.json /
715
+ // installed.json), workspace files in the root package's "." bucket. Files with
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 }>.
719
+ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
720
+ const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
721
+ const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
722
+ const installed = loadInstalledVersions(baseDir, vendorDir)
723
+
724
+ const modules = new Map()
725
+ const ensureBucket = (dir, name, version, ecosystem) => {
726
+ if (!modules.has(dir)) {
727
+ modules.set(dir, ecosystem === undefined
728
+ ? { name, version, files: Object.create(null) }
729
+ : { name, version, ecosystem, files: Object.create(null) })
730
+ }
731
+ return modules.get(dir)
732
+ }
733
+
734
+ for (const [path, content] of sources) {
735
+ const pkg = findComposerPackage(baseDir, path, installed, fallbackVersion)
736
+ if (pkg) {
737
+ const rel = pkg.pkgDir === '.' ? path : path.slice(pkg.pkgDir.length + 1)
738
+ // Anything below the root composer package is an installed Composer
739
+ // dependency (vendor/<vendor>/<pkg>); tag it `composer`. The root "."
740
+ // bucket is the workspace's own code and stays ecosystem-less.
741
+ const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
742
+ ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
743
+ } else {
744
+ ensureBucket('.', fallbackName, fallbackVersion).files[path] = content
745
+ }
746
+ }
747
+ return modules
748
+ }
749
+
750
+ // Laravel auto-registers service providers that nothing references statically:
751
+ // vendor packages declare them under `extra.laravel.providers` (aggregated into
752
+ // vendor/composer/installed.json), and the app lists its own in
753
+ // bootstrap/providers.php (Laravel 11) or composer.json's `extra.laravel`. Those
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.
760
+ export function loadLaravelProviderFiles(baseDir, autoload) {
761
+ const files = new Set()
762
+ if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
763
+ if (!autoload) return [...files]
764
+
765
+ const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
766
+ const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
767
+ const installed = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
768
+ const installedPackages = Array.isArray(installed) ? installed : (installed?.packages ?? [])
769
+
770
+ const fqcns = new Set(composerJson?.extra?.laravel?.providers ?? [])
771
+ for (const pkg of installedPackages) {
772
+ for (const fqcn of pkg?.extra?.laravel?.providers ?? []) fqcns.add(fqcn)
773
+ }
774
+ for (const fqcn of fqcns) {
775
+ const file = resolveClassFile(fqcn, autoload, baseDir)
776
+ if (file) files.add(file)
777
+ }
778
+ return [...files]
779
+ }
780
+
781
+ // --- Class-reference extraction ----------------------------------------------
782
+
783
+ const RESERVED = new Set([
784
+ // pseudo-types / class-context keywords
785
+ 'self', 'static', 'parent', 'this',
786
+ // scalar + special type names
787
+ 'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean', 'void',
788
+ 'array', 'callable', 'iterable', 'object', 'mixed', 'null', 'false', 'true',
789
+ '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
+ 'return', 'echo', 'print', 'clone', 'yield', 'throw', 'global', 'and', 'or', 'xor',
794
+ 'as', 'else', 'elseif', 'do', 'class', 'fn', 'match', 'enddeclare', 'endforeach',
795
+ 'endif', 'endswitch', 'endwhile', 'endfor',
796
+ ])
797
+
798
+ // Blank out PHP heredocs/nowdocs, comments AND string literals so class-
799
+ // reference scans don't trip over namespace-like text, braces, or keywords
800
+ // inside them. Unlike `maskCommentsAndHeredocs`, this also removes string
801
+ // contents -- class references never live in strings.
802
+ function stripPhp(content) {
803
+ return maskNonCode(content, false)
804
+ }
805
+
806
+ // Add the FQCN for a class reference written as `raw` in a file whose namespace
807
+ // is `ns`, given the file's `use`-alias map. Mirrors PHP name resolution: a
808
+ // leading `\` is fully-qualified; a first segment matching an alias expands
809
+ // through it; otherwise an unqualified/qualified name is relative to the
810
+ // current namespace.
811
+ function addRef(set, raw, ns, aliasMap) {
812
+ const n = raw.trim().replace(/^\?/u, '') // drop a nullable-type marker (`?Foo`)
813
+ if (!n || !/^\\?[A-Za-z_][\w\\]*$/u.test(n)) return
814
+ if (n.startsWith('\\')) {
815
+ set.add(n.replace(/^\\+/u, ''))
816
+ return
817
+ }
818
+ const first = n.split('\\')[0]
819
+ if (!n.includes('\\')) {
820
+ if (RESERVED.has(n.toLowerCase())) return
821
+ if (aliasMap.has(n)) set.add(aliasMap.get(n))
822
+ else set.add(ns ? `${ns}\\${n}` : n)
823
+ } else if (aliasMap.has(first)) {
824
+ set.add(`${aliasMap.get(first)}\\${n.slice(first.length + 1)}`)
825
+ } else {
826
+ set.add(ns ? `${ns}\\${n}` : n)
827
+ }
828
+ }
829
+
830
+ // Parse the body of a `use` statement (everything between `use` and `;`,
831
+ // already trimmed) into its entries. Handles comma lists (`A, B as C`) and
832
+ // group syntax (`Pre\{A, B as C}`). Returns `[{ name, short }]` where `name` is
833
+ // the symbol as written (sans alias) and `short` is the alias or last segment.
834
+ function parseUseBody(body) {
835
+ const grouped = body.match(/^([\w\\]+\\)\{([\s\S]*)\}$/u)
836
+ const entries = grouped
837
+ ? grouped[2].split(',').map((p) => `${grouped[1]}${p.trim()}`)
838
+ : body.split(',')
839
+ const out = []
840
+ for (const entry of entries) {
841
+ const [name, alias] = entry.trim().split(/\s+as\s+/iu).map((s) => s.trim())
842
+ if (!name || !/^\\?[\w\\]+$/u.test(name)) continue
843
+ const fqcn = name.replace(/^\\+/u, '')
844
+ out.push({ name, short: alias || fqcn.split('\\').pop() })
845
+ }
846
+ return out
847
+ }
848
+
849
+ // Position of the first class/interface/trait/enum declaration keyword. PHP
850
+ // requires `use` *imports* to appear before any declaration, so this marks the
851
+ // end of the import region: a `use` before it is an import (an alias that loads
852
+ // nothing), while a `use` after it is a trait use inside a class body (which
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.
857
+ function firstDeclPos(stripped) {
858
+ const m = stripped.match(/(?<![:\w$\\>])\b(?:class|interface|trait|enum)\b/iu)
859
+ return m ? m.index : Infinity
860
+ }
861
+
862
+ // Collect the set of fully-qualified class names a PHP file actually depends
863
+ // on. A `use` import only creates an alias and loads nothing, so it is NOT a
864
+ // dependency on its own -- only classes referenced at a real site pull a file
865
+ // in: new / extends / implements / instanceof / catch / `Foo::bar` / attributes
866
+ // / parameter+return type hints / fully-qualified names, plus in-body
867
+ // `use Trait;` (which does load the trait).
868
+ export function phpClassDependencies(content) {
869
+ const stripped = stripPhp(content)
870
+ const nsMatch = stripped.match(/\bnamespace\s+([\w\\]+)/u)
871
+ const ns = nsMatch ? nsMatch[1].replace(/^\\+|\\+$/gu, '') : ''
872
+ const set = new Set()
873
+
874
+ // Classify `use` statements: those before the first declaration are imports
875
+ // (alias map only); those after are trait uses inside a class body, which
876
+ // count as references.
877
+ const declAt = firstDeclPos(stripped)
878
+ const aliasMap = new Map()
879
+ const traitRefs = []
880
+ for (const m of stripped.matchAll(/\buse\s+(?!\()(function\s+|const\s+)?([\s\S]*?);/giu)) {
881
+ if (m[1]) continue
882
+ const entries = parseUseBody(m[2].trim())
883
+ if (m.index < declAt) {
884
+ for (const e of entries) aliasMap.set(e.short, e.name.replace(/^\\+/u, ''))
885
+ } else {
886
+ for (const e of entries) traitRefs.push(e.name)
887
+ }
888
+ }
889
+
890
+ const add = (raw) => addRef(set, raw, ns, aliasMap)
891
+ for (const raw of traitRefs) add(raw)
892
+
893
+ for (const m of stripped.matchAll(/(?<![\w$\\])new\s+(\\?[\w\\]+)/giu)) add(m[1])
894
+ for (const m of stripped.matchAll(/(?<![\w$\\])instanceof\s+(\\?[\w\\]+)/giu)) add(m[1])
895
+ for (const m of stripped.matchAll(/(?<![\w$\\])(\\?[\w\\]+)\s*::/giu)) add(m[1])
896
+ for (const m of stripped.matchAll(/#\[\s*(\\?[\w\\]+)/giu)) add(m[1])
897
+
898
+ // class/interface/trait/enum headers: pull names out of extends/implements.
899
+ for (const m of stripped.matchAll(/\b(?:class|interface|trait|enum)\b[^{;]*?\{/giu)) {
900
+ const header = m[0]
901
+ const ext = header.match(/\bextends\s+([\w\\\s,?]+?)(?:\bimplements\b|\{)/iu)
902
+ if (ext) for (const nm of ext[1].split(',')) add(nm)
903
+ const impl = header.match(/\bimplements\s+([\w\\\s,?]+?)\{/iu)
904
+ if (impl) for (const nm of impl[1].split(',')) add(nm)
905
+ }
906
+
907
+ // catch (A | B $e): capture the type list up to the `$var`. A single lazy
908
+ // `[^)]*?` (no second whitespace quantifier) keeps this linear even when a
909
+ // long blanked run sits inside the parens with no `$var`.
910
+ for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
911
+ for (const nm of m[1].split('|')) add(nm)
912
+ }
913
+
914
+ // parameter / property type hints: `Type $var`, incl. nullable `?Type`,
915
+ // variadic `Type ...$var`, by-ref `Type &$var`, and union/intersection
916
+ // `A|B`/`A&B $var` (each member counts). The separator before `$var` is one
917
+ // `\s+` plus an optional `&`/`...` run -- written so only a single whitespace
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.
921
+ for (const m of stripped.matchAll(/(?<![\w$\\])([?\w\\|&]+)\s+(?:[&.]+\s*)?\$\w+/giu)) {
922
+ for (const nm of m[1].split(/[|&]/u)) add(nm)
923
+ }
924
+ // return types: `): Type`, incl. nullable and union/intersection `): A|B`.
925
+ for (const m of stripped.matchAll(/\)\s*:\s*([?\w\\|&]+)/giu)) {
926
+ for (const nm of m[1].split(/[|&]/u)) add(nm)
927
+ }
928
+
929
+ return set
930
+ }
931
+
932
+ // --- Tree / disk collection --------------------------------------------------
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.
937
+ function autoloadEdges(content, autoload, baseDir, sources) {
938
+ const edges = new Map()
939
+ if (!autoload) return edges
940
+ for (const fqcn of phpClassDependencies(content)) {
941
+ const file = resolveClassFile(fqcn, autoload, baseDir)
942
+ if (file && (sources === null || sources.has(file))) edges.set(fqcn, file)
943
+ }
944
+ return edges
945
+ }
946
+
947
+ // Build the { sources, resolutions, missing } triple from a Map of already-
948
+ // loaded PHP sources. Explicit includes are strict (an unresolvable one is
949
+ // recorded in `missing`); Composer-autoloaded class references, when an
950
+ // `autoload` config is supplied, are recorded best-effort and never added to
951
+ // `missing`. As a final include fallback, a specifier that matches a stored
952
+ // key verbatim is accepted.
953
+ export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
954
+ const resolutions = new Map()
955
+ const missing = []
956
+ for (const [path, content] of sources) {
957
+ const specMap = new Map()
958
+ for (const spec of extractPhpImports(content)) {
959
+ let resolved = resolvePhpImport(spec, path, { baseDir })
960
+ if (resolved && !sources.has(resolved)) resolved = null
961
+ if (!resolved && sources.has(spec)) resolved = spec
962
+ if (resolved) {
963
+ specMap.set(spec, resolved)
964
+ } else {
965
+ console.warn(`[loader.php] Missing import: ${spec} from ${path}`)
966
+ missing.push({ spec, from: path })
967
+ }
968
+ }
969
+ for (const [fqcn, file] of autoloadEdges(content, autoload, baseDir, sources)) {
970
+ specMap.set(fqcn, file)
971
+ }
972
+ resolutions.set(path, specMap)
973
+ }
974
+ return { sources, resolutions, missing }
975
+ }
976
+
977
+ // Walk the filesystem starting from `entries` (plus any Composer `files`
978
+ // autoload entries), following resolved includes and autoloaded class
979
+ // references, reading each file exactly once. Files in the same wave are read
980
+ // in parallel; the next wave depends on what the previous one referenced.
981
+ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = null } = {}) {
982
+ const sources = new Map()
983
+ const realBase = realpathSync(baseDir)
984
+
985
+ const readOne = async (relPath) => {
986
+ try {
987
+ assertRealPathWithinBase(realBase, baseDir, relPath)
988
+ return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
989
+ } catch (err) {
990
+ // ENOENT: target doesn't exist. EISDIR: a specifier resolved to a
991
+ // directory (e.g. an extensionless `require __DIR__ . '/dir'`). Both
992
+ // mean "not a loadable file" -- warn and skip rather than crash; the
993
+ // unresolved edge is surfaced later by buildPhpTree for real includes.
994
+ if (err.code === 'ENOENT' || err.code === 'EISDIR') {
995
+ console.warn(`[loader.php] Missing import: ${relPath}`)
996
+ return null
997
+ }
998
+ throw err
999
+ }
1000
+ }
1001
+
1002
+ const processWave = async (wave) => {
1003
+ const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
1004
+ if (toLoad.length === 0) return
1005
+ // Read in bounded batches: a single wave can be huge (e.g. a dynamic
1006
+ // include's directory glob over thousands of files), and an unbounded
1007
+ // Promise.all would open every file at once and crash with EMFILE.
1008
+ const reads = []
1009
+ for (let k = 0; k < toLoad.length; k += READ_CONCURRENCY) {
1010
+ // eslint-disable-next-line no-await-in-loop -- batches are intentionally sequential to cap open files
1011
+ reads.push(...(await Promise.all(toLoad.slice(k, k + READ_CONCURRENCY).map(readOne))))
1012
+ }
1013
+ const next = []
1014
+ for (const entry of reads) {
1015
+ if (!entry) continue
1016
+ const [relPath, content] = entry
1017
+ sources.set(relPath, content)
1018
+ for (const spec of extractPhpImports(content)) {
1019
+ const resolved = resolvePhpImport(spec, relPath, { baseDir })
1020
+ if (resolved) {
1021
+ if (!sources.has(resolved)) next.push(resolved)
1022
+ } else {
1023
+ console.warn(`[loader.php] Missing import: ${spec} from ${relPath}`)
1024
+ }
1025
+ }
1026
+ // Dynamic includes with a static directory prefix (e.g.
1027
+ // `require __DIR__ . "/views/$view.php"`): the exact file is only known at
1028
+ // runtime, so bundle every `.php` file in that directory as a candidate.
1029
+ for (const dirSpec of extractPhpImportDirs(content)) {
1030
+ const dir = resolvePhpDir(dirSpec, relPath, baseDir)
1031
+ if (!dir) continue
1032
+ for (const file of listPhpFiles(baseDir, dir)) {
1033
+ if (!sources.has(file)) next.push(file)
1034
+ }
1035
+ }
1036
+ // `.php` path literals not in a require (e.g. route/config files passed
1037
+ // to a framework). Dir-anchored refs resolve against the file; Laravel
1038
+ // helper refs (`base_path(...)`) against the project root. Best-effort:
1039
+ // bundle only if they resolve to a file that exists.
1040
+ for (const { spec, anchor } of extractPhpPathRefs(content)) {
1041
+ const resolved = anchor === 'root'
1042
+ ? normalizeProjectRel(baseDir, spec)
1043
+ : resolvePhpImport(spec, relPath, { baseDir })
1044
+ if (resolved && isFileOnDisk(baseDir, resolved) && !sources.has(resolved)) next.push(resolved)
1045
+ }
1046
+ // Autoloaded class references (best-effort): enqueue whatever resolves.
1047
+ for (const [, file] of autoloadEdges(content, autoload, baseDir, null)) {
1048
+ if (!sources.has(file)) next.push(file)
1049
+ }
1050
+ }
1051
+ await processWave(next)
1052
+ }
1053
+
1054
+ // `files` autoload entries are required unconditionally by Composer, so they
1055
+ // are roots of the graph alongside the caller's entries.
1056
+ await processWave([...entries, ...(autoload?.files ?? [])])
1057
+ return sources
1058
+ }