@exodus/stasis 1.0.0-alpha.3 → 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.
- package/README.md +67 -3
- package/bin/stasis.js +322 -17
- package/logo.svg +12 -3
- package/package.json +38 -18
- package/src/audit.js +1 -38
- package/src/bundle-from-lockfile.js +65 -0
- package/src/bundle.js +4 -217
- package/src/cmd/build.js +321 -0
- package/src/cmd/bundle.js +949 -58
- package/src/cmd/diff.js +36 -0
- package/src/cmd/extract.js +193 -0
- package/src/cmd/sbom.js +35 -0
- package/src/diff.js +333 -0
- package/src/esbuild.js +5 -56
- package/src/loader-mock.js +19 -0
- package/src/loader.js +6 -132
- package/src/loaders/bash.js +257 -0
- package/src/loaders/php.js +1058 -0
- package/src/loaders/rust.js +336 -0
- package/src/loaders/solidity.js +26 -1
- package/src/lockfile.js +4 -80
- package/src/metro-transformer.js +6 -0
- package/src/metro.js +5 -0
- package/src/mock.js +310 -0
- package/src/parse.js +53 -0
- package/src/prune.js +4 -162
- package/src/sbom.js +266 -0
- package/src/scan.js +373 -0
- package/src/webpack.js +5 -48
- package/src/config.js +0 -205
- package/src/state.js +0 -440
- package/src/state.util.js +0 -25
- package/src/util.js +0 -60
|
@@ -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
|
+
}
|