@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/bin/stasis.js +375 -170
- package/package.json +44 -11
- package/src/audit-corrections.js +68 -0
- package/src/audit.js +243 -59
- package/src/babel-worker.cjs +65 -0
- package/src/babel.js +131 -0
- package/src/bundle-from-lockfile.js +12 -18
- package/src/bundle-reader.js +77 -0
- package/src/cmd/build.js +95 -128
- package/src/cmd/bundle.js +877 -641
- package/src/cmd/diff.js +2 -19
- package/src/cmd/github-bundle.js +39 -0
- package/src/cmd/output.js +36 -0
- package/src/cmd/sbom.js +4 -18
- package/src/diff.js +67 -124
- package/src/json-stream.js +283 -0
- package/src/loader-mock.js +8 -15
- package/src/loaders/bash.js +49 -116
- package/src/loaders/cargo.js +1699 -0
- package/src/loaders/foundry.js +923 -0
- package/src/loaders/paths.js +33 -0
- package/src/loaders/php.js +339 -394
- package/src/loaders/rust.js +3254 -242
- package/src/loaders/solidity-ownership.js +531 -0
- package/src/loaders/solidity.js +412 -162
- package/src/loaders/toml.js +68 -0
- package/src/metro-resolver.js +150 -0
- package/src/mock.js +57 -165
- package/src/parse.js +4 -16
- package/src/resolve-fields.js +331 -0
- package/src/resolve-node.js +416 -0
- package/src/resolve-typescript.js +427 -0
- package/src/rollup.js +5 -0
- package/src/sbom.js +64 -92
- package/src/scan.js +234 -156
- package/src/vfs-bundle/entries.js +140 -0
- package/src/vfs-bundle/github.js +193 -0
- package/src/vfs-bundle/soldeer.js +125 -0
- package/src/vfs-bundle/tree.js +606 -0
- package/src/vfs-bundle.js +74 -0
- package/src/why.js +434 -0
- package/src/apis/npm/index.js +0 -36
- package/src/apis/npm/semver.cjs +0 -44
- package/src/cmd/extract.js +0 -193
package/src/loaders/php.js
CHANGED
|
@@ -1,52 +1,26 @@
|
|
|
1
|
-
// PHP include-graph + Composer-autoload loader
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
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'
|
|
1
|
+
// PHP include-graph + Composer-autoload loader. Statically scans PHP (never runs
|
|
2
|
+
// it) into `{ sources, resolutions, missing }`. Literal includes are strict
|
|
3
|
+
// (unresolved -> `missing`); Composer-autoloaded class refs are best-effort
|
|
4
|
+
// (unresolved -> silently skipped).
|
|
5
|
+
|
|
6
|
+
import { isUtf8 } from 'node:buffer'
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
36
8
|
import { readFile } from 'node:fs/promises'
|
|
37
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
10
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
38
11
|
|
|
39
|
-
import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
|
|
12
|
+
import { assertRealPathWithinBase, isPlainObject, relativeEscapes, toPosix } from '@exodus/stasis-core/util'
|
|
13
|
+
import { LockfileError, parseComposerLock } from '@preventive/lockfile/composer.js'
|
|
14
|
+
import { isDir, isFile } from '../resolve-typescript.js'
|
|
15
|
+
import { applyToDir } from './paths.js'
|
|
16
|
+
import { readUtf8OrNull } from './solidity-ownership.js'
|
|
17
|
+
import { nameErrors } from './toml.js'
|
|
40
18
|
|
|
41
|
-
//
|
|
19
|
+
// Cap concurrent reads per wave to avoid EMFILE on large directory globs.
|
|
42
20
|
const READ_CONCURRENCY = 64
|
|
43
21
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
// End index (exclusive) of the heredoc/nowdoc opening at `start` (`<<<`), or
|
|
47
|
-
// `start` itself if it isn't a valid opener. Consumes to EOF when unterminated.
|
|
48
|
-
// Recognises the indented closing label (PHP 7.3+) and the leading quote of
|
|
49
|
-
// nowdoc/quoted-heredoc labels.
|
|
22
|
+
// End index (exclusive) of the heredoc/nowdoc opener at `start`, or `start` if
|
|
23
|
+
// not a valid opener. Consumes to EOF when unterminated.
|
|
50
24
|
function heredocEnd(code, start) {
|
|
51
25
|
const n = code.length
|
|
52
26
|
let j = start + 3
|
|
@@ -76,15 +50,9 @@ function heredocEnd(code, start) {
|
|
|
76
50
|
return n
|
|
77
51
|
}
|
|
78
52
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// (newlines preserved), so the output is the same length as the input and byte
|
|
83
|
-
// offsets stay aligned. Strings are scanned in the same loop as comments, so a
|
|
84
|
-
// `//`/`#` inside a string is never mistaken for a comment, and `#[` (a PHP 8
|
|
85
|
-
// attribute) is left intact. A hand-written scanner (rather than one big regex)
|
|
86
|
-
// keeps this strictly linear -- immune to the catastrophic backtracking a regex
|
|
87
|
-
// suffers on unterminated strings/heredocs in adversarial input.
|
|
53
|
+
// Blank everything that isn't executable PHP (comments, heredocs/nowdocs, and
|
|
54
|
+
// unless `keepStrings` string literals) to spaces, preserving newlines so byte
|
|
55
|
+
// offsets stay aligned with the input. `#[` (a PHP 8 attribute) is left intact.
|
|
88
56
|
function maskNonCode(code, keepStrings) {
|
|
89
57
|
const n = code.length
|
|
90
58
|
const blank = (s) => s.replace(/[^\n]/gu, ' ')
|
|
@@ -122,18 +90,14 @@ function maskNonCode(code, keepStrings) {
|
|
|
122
90
|
return out
|
|
123
91
|
}
|
|
124
92
|
|
|
125
|
-
//
|
|
126
|
-
// strings). See maskNonCode.
|
|
93
|
+
// Blank comments/heredocs, keep string literals (include paths live in strings).
|
|
127
94
|
function maskCommentsAndHeredocs(content) {
|
|
128
95
|
return maskNonCode(content, true)
|
|
129
96
|
}
|
|
130
97
|
|
|
131
|
-
// Scan
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// Knowing the real boundaries means quote pairing is never guessed: an include
|
|
135
|
-
// keyword sitting inside a string is recognisable, and a string can't be
|
|
136
|
-
// mis-paired with a quote belonging to the next concatenated string.
|
|
98
|
+
// Scan string literals -> { start, end, value, quote } with PHP single-quote
|
|
99
|
+
// unescaping (only `\\` and the closing quote are escapes; other backslashes
|
|
100
|
+
// literal -- good enough for path values).
|
|
137
101
|
function stringLiterals(code) {
|
|
138
102
|
const out = []
|
|
139
103
|
let i = 0
|
|
@@ -163,25 +127,23 @@ function stringLiterals(code) {
|
|
|
163
127
|
return out
|
|
164
128
|
}
|
|
165
129
|
|
|
166
|
-
//
|
|
130
|
+
// Whether `pos` is inside one of `strings` (stringLiterals), and the literal starting at `pos`.
|
|
131
|
+
const inString = (strings, pos) => strings.some(({ start, end }) => pos > start && pos < end)
|
|
132
|
+
const stringAt = (strings, pos) => strings.find(({ start }) => start === pos)
|
|
167
133
|
|
|
168
|
-
//
|
|
169
|
-
// (`$x->require
|
|
170
|
-
//
|
|
171
|
-
// inside `requireConfig`. Matches inside string literals are filtered out by
|
|
172
|
-
// the caller using the real string spans.
|
|
134
|
+
// Include keyword. The lookbehind rejects method/scope-resolution calls
|
|
135
|
+
// (`$x->require`, `Foo::include`); the trailing `\b` stops matching inside
|
|
136
|
+
// `requireConfig`. String-literal matches are filtered out by the caller.
|
|
173
137
|
const PHP_INCLUDE_KEYWORD_RE = /(?<![\w$>:])(?:require_once|require|include_once|include)\b/gidu
|
|
174
138
|
|
|
175
|
-
// Directory anchor
|
|
176
|
-
//
|
|
177
|
-
// up -- the idiom for locating bootstrap/vendor/config relative to a file).
|
|
139
|
+
// Directory anchor (sticky): `__DIR__`/`dirname(__FILE__)` (file's own dir) or
|
|
140
|
+
// `dirname(__DIR__[, N])` (N parent levels up).
|
|
178
141
|
const PHP_DIR_ANCHOR_RE = /__DIR__\b|dirname\s*\(\s*(__FILE__|__DIR__)\s*(?:,\s*(\d+)\s*)?\)/y
|
|
179
142
|
// Anchor starts, for scanning path literals anywhere (not just after `require`).
|
|
180
143
|
const PHP_DIR_ANCHOR_FIND_RE = /(?<![\w$>])(?:__DIR__\b|dirname\s*\(\s*(?:__FILE__|__DIR__))/gu
|
|
181
144
|
|
|
182
|
-
// Laravel path helpers
|
|
183
|
-
//
|
|
184
|
-
// `config_path('app.php')`, … name real PHP files that the framework require()s.
|
|
145
|
+
// Laravel path helpers resolving a string arg relative to the project root (or a
|
|
146
|
+
// fixed subdirectory); they name real PHP files the framework require()s.
|
|
185
147
|
const PHP_PATH_HELPERS = new Map([
|
|
186
148
|
['base_path', ''],
|
|
187
149
|
['app_path', 'app'],
|
|
@@ -205,8 +167,7 @@ function skipWs(code, pos) {
|
|
|
205
167
|
}
|
|
206
168
|
|
|
207
169
|
// If a directory anchor starts at `pos`, return { end, up } (up = parent levels
|
|
208
|
-
// above the file's
|
|
209
|
-
// `dirname(__DIR__[, N])`); otherwise null.
|
|
170
|
+
// above the file's dir; 0 for `__DIR__`/`dirname(__FILE__)`); else null.
|
|
210
171
|
function matchDirAnchor(code, pos) {
|
|
211
172
|
PHP_DIR_ANCHOR_RE.lastIndex = pos
|
|
212
173
|
const m = PHP_DIR_ANCHOR_RE.exec(code)
|
|
@@ -215,26 +176,23 @@ function matchDirAnchor(code, pos) {
|
|
|
215
176
|
return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
|
|
216
177
|
}
|
|
217
178
|
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
179
|
+
// A `./`/`../` specifier, as PHP tells one (a `.`-led NAME like `..cache/a.php` is a bare one).
|
|
180
|
+
const isDotRelative = (spec) => spec === '.' || spec === '..' || spec.startsWith('./') || spec.startsWith('../')
|
|
181
|
+
|
|
182
|
+
// Normalise an include path to a specifier. Dir-relative paths get a `./` prefix
|
|
183
|
+
// with the leading separator stripped; empty means the file's own dir (`.`).
|
|
221
184
|
function normalizeIncludePath(raw, dirRelative) {
|
|
222
185
|
if (!dirRelative) return raw
|
|
223
186
|
const rel = raw.replace(/^\/+/u, '')
|
|
224
187
|
if (rel === '') return '.'
|
|
225
|
-
return rel
|
|
188
|
+
return isDotRelative(rel) ? rel : `./${rel}`
|
|
226
189
|
}
|
|
227
190
|
|
|
228
|
-
// Parse an include/path argument
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// variable/function/constant or `"…$interp…"`. Parent levels from the anchor
|
|
233
|
-
// (`dirname(__DIR__, N)`) are folded in as leading `/..`. Returns null when
|
|
234
|
-
// there's neither an anchor nor any static string. The accumulated `prefix` is
|
|
235
|
-
// the static path up to the first dynamic piece.
|
|
191
|
+
// Parse an include/path argument at `start`: an optional directory anchor then a
|
|
192
|
+
// concatenation chain of string literals (static literals fold together). Goes
|
|
193
|
+
// `dynamic` at the first variable/function/constant/interpolation; `prefix` is
|
|
194
|
+
// the static path up to there. Returns null with neither anchor nor static string.
|
|
236
195
|
function parseArgPath(code, start, strings) {
|
|
237
|
-
const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
|
|
238
196
|
let i = start
|
|
239
197
|
let dirRelative = false
|
|
240
198
|
let up = 0
|
|
@@ -243,7 +201,7 @@ function parseArgPath(code, start, strings) {
|
|
|
243
201
|
i = skipWs(code, anchor.end)
|
|
244
202
|
dirRelative = true
|
|
245
203
|
up = anchor.up
|
|
246
|
-
if (code[i] !== '.') return null
|
|
204
|
+
if (code[i] !== '.') return null
|
|
247
205
|
i = skipWs(code, i + 1)
|
|
248
206
|
}
|
|
249
207
|
|
|
@@ -251,34 +209,30 @@ function parseArgPath(code, start, strings) {
|
|
|
251
209
|
let dynamic = false
|
|
252
210
|
let sawStatic = false
|
|
253
211
|
for (;;) {
|
|
254
|
-
const lit = stringAt(i)
|
|
255
|
-
if (!lit) { dynamic = true; break }
|
|
212
|
+
const lit = stringAt(strings, i)
|
|
213
|
+
if (!lit) { dynamic = true; break }
|
|
256
214
|
const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
|
|
257
215
|
if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
|
|
258
216
|
prefix += lit.value
|
|
259
217
|
sawStatic = true
|
|
260
218
|
i = skipWs(code, lit.end)
|
|
261
|
-
if (code[i] === '.') { i = skipWs(code, i + 1); continue }
|
|
219
|
+
if (code[i] === '.') { i = skipWs(code, i + 1); continue }
|
|
262
220
|
break
|
|
263
221
|
}
|
|
264
222
|
if (!dirRelative && !sawStatic) return null
|
|
265
223
|
return { dirRelative, up, prefix, dynamic, sawStatic }
|
|
266
224
|
}
|
|
267
225
|
|
|
268
|
-
// Scan
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// target, e.g. Laravel's `require __DIR__ . "/.../components/$view.php"`). Works
|
|
272
|
-
// on comment/heredoc-masked code and real string-literal boundaries (never regex
|
|
273
|
-
// quote-pairing); keyword matches inside a string are skipped.
|
|
226
|
+
// Scan `require`/`include` statements, classifying each as a concrete `file` or
|
|
227
|
+
// -- when the path is dynamic but has a static directory prefix -- a `dir` whose
|
|
228
|
+
// `.php` files are all bundled (any could be the runtime target).
|
|
274
229
|
function scanIncludes(content) {
|
|
275
230
|
const code = maskCommentsAndHeredocs(content)
|
|
276
231
|
const strings = stringLiterals(code)
|
|
277
|
-
const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
|
|
278
232
|
|
|
279
233
|
const out = []
|
|
280
234
|
for (const m of code.matchAll(PHP_INCLUDE_KEYWORD_RE)) {
|
|
281
|
-
if (inString(m.index)) continue
|
|
235
|
+
if (inString(strings, m.index)) continue
|
|
282
236
|
let i = skipWs(code, m.index + m[0].length)
|
|
283
237
|
if (code[i] === '(') i = skipWs(code, i + 1)
|
|
284
238
|
const arg = parseArgPath(code, i, strings)
|
|
@@ -289,43 +243,36 @@ function scanIncludes(content) {
|
|
|
289
243
|
if (sawStatic) out.push({ kind: 'file', spec: normalizeIncludePath(prefix, dirRelative) })
|
|
290
244
|
continue
|
|
291
245
|
}
|
|
292
|
-
// Dynamic path: fall back to the static
|
|
293
|
-
// separator). A bare path with no separator has no usable directory.
|
|
246
|
+
// Dynamic path: fall back to the static dir prefix; a bare path with no separator has none.
|
|
294
247
|
if (!dirRelative && !prefix.includes('/')) continue
|
|
295
248
|
const lastSlash = prefix.lastIndexOf('/')
|
|
296
249
|
let dir = lastSlash >= 0 ? prefix.slice(0, lastSlash) : ''
|
|
297
|
-
if (dir === '' && up > 0) dir = '/..'.repeat(up)
|
|
250
|
+
if (dir === '' && up > 0) dir = '/..'.repeat(up)
|
|
298
251
|
out.push({ kind: 'dir', spec: normalizeIncludePath(dir, dirRelative) })
|
|
299
252
|
}
|
|
300
253
|
return out
|
|
301
254
|
}
|
|
302
255
|
|
|
303
|
-
// Dir-anchored static `.php` path literals anywhere in the file (not
|
|
304
|
-
// `require
|
|
305
|
-
//
|
|
306
|
-
// '/../routes/web.php', …)`. These are best-effort: bundled only if they resolve
|
|
307
|
-
// to a file that exists (see collectPhpFilesFromDisk), so a path that's just a
|
|
308
|
-
// string and never loaded does no harm.
|
|
256
|
+
// Dir-anchored static `.php` path literals anywhere in the file (not just after
|
|
257
|
+
// `require`) -- frameworks pass these to code that require()s them internally.
|
|
258
|
+
// Best-effort: bundled only if they resolve to an existing file.
|
|
309
259
|
function scanPathLiterals(content) {
|
|
310
260
|
const code = maskCommentsAndHeredocs(content)
|
|
311
261
|
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
262
|
|
|
315
263
|
const out = []
|
|
316
|
-
// Dir-anchored literals: `__DIR__ . '/x.php'` (
|
|
264
|
+
// Dir-anchored literals: `__DIR__ . '/x.php'` (resolve against the file).
|
|
317
265
|
for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
|
|
318
|
-
if (inString(m.index)) continue
|
|
266
|
+
if (inString(strings, m.index)) continue
|
|
319
267
|
const arg = parseArgPath(code, m.index, strings)
|
|
320
268
|
if (!arg || arg.dynamic || !arg.sawStatic || !arg.dirRelative) continue
|
|
321
269
|
const spec = normalizeIncludePath(arg.prefix, true)
|
|
322
270
|
if (spec.endsWith('.php')) out.push({ spec, anchor: 'file' })
|
|
323
271
|
}
|
|
324
|
-
// Laravel path helpers: `base_path('routes/web.php')`
|
|
325
|
-
// against the project root, with the helper's fixed subdirectory prefixed).
|
|
272
|
+
// Laravel path helpers: `base_path('routes/web.php')` (resolve against project root).
|
|
326
273
|
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))
|
|
274
|
+
if (inString(strings, m.index)) continue
|
|
275
|
+
const lit = stringAt(strings, skipWs(code, m.index + m[0].length))
|
|
329
276
|
if (!lit || (lit.quote === '"' && PHP_INTERPOLATION_RE.test(lit.value))) continue
|
|
330
277
|
const sub = PHP_PATH_HELPERS.get(m[1].toLowerCase())
|
|
331
278
|
const arg = lit.value.replace(/^\/+/u, '')
|
|
@@ -335,21 +282,21 @@ function scanPathLiterals(content) {
|
|
|
335
282
|
return out
|
|
336
283
|
}
|
|
337
284
|
|
|
285
|
+
const includeSpecs = (includes, kind) => includes.filter((e) => e.kind === kind).map((e) => e.spec)
|
|
286
|
+
|
|
338
287
|
// Statically-resolvable include specifiers (concrete files). See scanIncludes.
|
|
339
288
|
export function extractPhpImports(content) {
|
|
340
|
-
return scanIncludes(content)
|
|
289
|
+
return includeSpecs(scanIncludes(content), 'file')
|
|
341
290
|
}
|
|
342
291
|
|
|
343
|
-
// Directory specifiers for dynamic includes
|
|
344
|
-
//
|
|
292
|
+
// Directory specifiers for dynamic includes; every `.php` file directly inside is
|
|
293
|
+
// a candidate target.
|
|
345
294
|
export function extractPhpImportDirs(content) {
|
|
346
|
-
return scanIncludes(content)
|
|
295
|
+
return includeSpecs(scanIncludes(content), 'dir')
|
|
347
296
|
}
|
|
348
297
|
|
|
349
|
-
// `.php` path literals used outside a `require
|
|
350
|
-
//
|
|
351
|
-
// (`base_path('routes/x.php')`, `anchor: 'root'`). Frameworks pass these to code
|
|
352
|
-
// that require()s them. De-duplicated; best-effort (bundled only if they exist).
|
|
298
|
+
// `.php` path literals used outside a `require` -- dir-anchored (`anchor: 'file'`)
|
|
299
|
+
// or via a Laravel path helper (`anchor: 'root'`). De-duplicated; best-effort.
|
|
353
300
|
export function extractPhpPathRefs(content) {
|
|
354
301
|
const seen = new Set()
|
|
355
302
|
return scanPathLiterals(content).filter(({ spec, anchor }) => {
|
|
@@ -360,63 +307,41 @@ export function extractPhpPathRefs(content) {
|
|
|
360
307
|
})
|
|
361
308
|
}
|
|
362
309
|
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
}
|
|
310
|
+
const isFileOnDisk = (baseDir, rel) => isFile(join(baseDir, rel))
|
|
311
|
+
const isDirOnDisk = (baseDir, rel) => isDir(join(baseDir, rel))
|
|
381
312
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
return false
|
|
387
|
-
}
|
|
313
|
+
// Throws on a path holding `\`: no separator to PHP off Windows but part of a name, and stasis
|
|
314
|
+
// refuses such a name rather than resolve it, or take it for another path. `from`: the includer.
|
|
315
|
+
function refuseBackslash(path, from) {
|
|
316
|
+
if (path.includes('\\')) throw new Error(`[loader.php] path holds a '\\', which stasis refuses: ${path}${from ? ` (from ${from})` : ''}`)
|
|
388
317
|
}
|
|
389
318
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return false
|
|
395
|
-
}
|
|
319
|
+
// `abs` as a baseDir-relative POSIX path ('' for baseDir itself), or null when it escapes the root.
|
|
320
|
+
function projectRel(baseDir, abs) {
|
|
321
|
+
const rel = toPosix(relative(baseDir, abs))
|
|
322
|
+
return relativeEscapes(rel) ? null : rel
|
|
396
323
|
}
|
|
397
324
|
|
|
398
|
-
// Resolve a directory specifier
|
|
399
|
-
//
|
|
400
|
-
//
|
|
325
|
+
// Resolve a directory specifier to an existing baseDir-relative dir, or null.
|
|
326
|
+
// `./`/`../` resolve against the including file; a bare spec tries file- then
|
|
327
|
+
// project-relative.
|
|
401
328
|
export function resolvePhpDir(spec, fromFile, baseDir) {
|
|
402
|
-
|
|
329
|
+
refuseBackslash(spec, fromFile)
|
|
330
|
+
if (isDotRelative(spec)) {
|
|
403
331
|
const rel = applyToDir(fromFile, spec.split('/'))
|
|
404
332
|
return rel !== null && isDirOnDisk(baseDir, rel) ? rel : null
|
|
405
333
|
}
|
|
406
334
|
if (baseDir && !isAbsolute(spec)) {
|
|
407
335
|
const fileRel = applyToDir(fromFile, spec.split('/'))
|
|
408
336
|
if (fileRel && isDirOnDisk(baseDir, fileRel)) return fileRel
|
|
409
|
-
const projRel =
|
|
410
|
-
if (projRel
|
|
411
|
-
return projRel
|
|
412
|
-
}
|
|
337
|
+
const projRel = projectRel(baseDir, resolve(baseDir, spec))
|
|
338
|
+
if (projRel && isDirOnDisk(baseDir, projRel)) return projRel
|
|
413
339
|
}
|
|
414
340
|
return null
|
|
415
341
|
}
|
|
416
342
|
|
|
417
|
-
// The `.php` files directly in `dir` (baseDir-relative)
|
|
418
|
-
//
|
|
419
|
-
// the directory itself, and recursing could pull in an unbounded subtree.
|
|
343
|
+
// The `.php` files directly in `dir` (baseDir-relative). Non-recursive: recursing
|
|
344
|
+
// could pull in an unbounded subtree.
|
|
420
345
|
function listPhpFiles(baseDir, dir) {
|
|
421
346
|
try {
|
|
422
347
|
return readdirSync(join(baseDir, dir), { withFileTypes: true })
|
|
@@ -427,16 +352,14 @@ function listPhpFiles(baseDir, dir) {
|
|
|
427
352
|
}
|
|
428
353
|
}
|
|
429
354
|
|
|
430
|
-
// Resolve a PHP include specifier to a baseDir-relative POSIX path.
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
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.
|
|
355
|
+
// Resolve a PHP include specifier to a baseDir-relative POSIX path. `./`/`../`
|
|
356
|
+
// resolve against the including file (escaping the root -> null); a bare specifier
|
|
357
|
+
// tries a file next to the includer, then project-root-relative. Absolute
|
|
358
|
+
// specifiers are rejected. Returns null when nothing resolves. One holding `\` throws
|
|
359
|
+
// (see refuseBackslash).
|
|
438
360
|
export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
439
|
-
|
|
361
|
+
refuseBackslash(specifier, fromFile)
|
|
362
|
+
if (isDotRelative(specifier)) {
|
|
440
363
|
return applyToDir(fromFile, specifier.split('/'))
|
|
441
364
|
}
|
|
442
365
|
|
|
@@ -444,35 +367,26 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
|
|
|
444
367
|
const fileRel = applyToDir(fromFile, specifier.split('/'))
|
|
445
368
|
if (fileRel && isFileOnDisk(baseDir, fileRel)) return fileRel
|
|
446
369
|
|
|
447
|
-
const projRel =
|
|
448
|
-
if (projRel
|
|
449
|
-
return projRel
|
|
450
|
-
}
|
|
370
|
+
const projRel = projectRel(baseDir, resolve(baseDir, specifier))
|
|
371
|
+
if (projRel && isFileOnDisk(baseDir, projRel)) return projRel
|
|
451
372
|
}
|
|
452
373
|
|
|
453
374
|
return null
|
|
454
375
|
}
|
|
455
376
|
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
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.
|
|
377
|
+
// Normalise a project-root-relative autoload path to POSIX baseDir-relative, or
|
|
378
|
+
// null when it escapes root. Re-derives via resolve() so interior `..` escapes
|
|
379
|
+
// (`src/foo/../../../secret.php`) are caught -- a leading-`..`/absolute check
|
|
380
|
+
// alone would let files outside the project into the bundle. One holding `\`
|
|
381
|
+
// throws (see refuseBackslash).
|
|
464
382
|
function normalizeProjectRel(baseDir, p) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (rel.startsWith('..') || isAbsolute(rel)) return null
|
|
468
|
-
return rel
|
|
383
|
+
refuseBackslash(p)
|
|
384
|
+
return projectRel(baseDir, resolve(baseDir, p))
|
|
469
385
|
}
|
|
470
386
|
|
|
471
|
-
// Evaluate a generated-autoload path
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
// or null when the expression contains anything we don't understand (a
|
|
475
|
-
// function call, an unknown variable) so we skip it rather than guess.
|
|
387
|
+
// Evaluate a generated-autoload path expr (`$baseDir . '/src'`, a bare quoted
|
|
388
|
+
// string) given the `$baseDir`/`$vendorDir` values. Returns null on anything
|
|
389
|
+
// unrecognised (function call, unknown variable) rather than guessing.
|
|
476
390
|
function evalPathExpr(expr, vars) {
|
|
477
391
|
const s = expr.trim().replace(/,+$/u, '').trim()
|
|
478
392
|
let out = ''
|
|
@@ -480,9 +394,8 @@ function evalPathExpr(expr, vars) {
|
|
|
480
394
|
const re = /\$(\w+)|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"|\.|\s+/gy
|
|
481
395
|
let m
|
|
482
396
|
while ((m = re.exec(s)) !== null) {
|
|
483
|
-
// A sticky regex resets lastIndex to 0
|
|
484
|
-
//
|
|
485
|
-
// the loop.
|
|
397
|
+
// A sticky regex resets lastIndex to 0 when exec() returns null, so track how
|
|
398
|
+
// far we parsed instead of reading lastIndex after the loop.
|
|
486
399
|
consumed = re.lastIndex
|
|
487
400
|
if (m[1] !== undefined) {
|
|
488
401
|
if (vars[m[1]] === undefined) return null
|
|
@@ -492,18 +405,14 @@ function evalPathExpr(expr, vars) {
|
|
|
492
405
|
} else if (m[3] !== undefined) {
|
|
493
406
|
out += m[3]
|
|
494
407
|
}
|
|
495
|
-
// '.' (concatenation) and whitespace contribute nothing
|
|
496
408
|
}
|
|
497
409
|
if (consumed !== s.length) return null
|
|
498
410
|
return out
|
|
499
411
|
}
|
|
500
412
|
|
|
501
|
-
// Parse
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// 'Fully\\Qualified\\Class' => $baseDir . '/X.php' // multi=false (classmap/files)
|
|
505
|
-
// Returns a Map of key -> (dirs[] when multi, file when not). PHP single-quote
|
|
506
|
-
// escapes (`\\`, `\'`) in keys are unfolded to real characters.
|
|
413
|
+
// Parse a Composer generated `autoload_*.php` map (one entry per line). `multi`
|
|
414
|
+
// true -> psr-4/psr-0 (key -> dirs[]); false -> classmap/files (key -> file).
|
|
415
|
+
// PHP single-quote escapes in keys are unfolded.
|
|
507
416
|
function parseGeneratedMap(content, vars, multi) {
|
|
508
417
|
const out = new Map()
|
|
509
418
|
for (const line of content.split('\n')) {
|
|
@@ -535,26 +444,135 @@ function readJsonIfExists(file) {
|
|
|
535
444
|
}
|
|
536
445
|
}
|
|
537
446
|
|
|
538
|
-
//
|
|
539
|
-
|
|
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) {
|
|
447
|
+
// The root composer.json (null when absent or unparsable) and its baseDir-relative vendor dir.
|
|
448
|
+
function readComposerJson(baseDir) {
|
|
549
449
|
const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
|
|
550
450
|
const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
|
|
451
|
+
return { composerJson, vendorDir }
|
|
452
|
+
}
|
|
551
453
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
454
|
+
// The packages `vendor/composer/installed.json` lists, in the Composer 2 ({ packages }) or
|
|
455
|
+
// Composer 1 (flat array) shape.
|
|
456
|
+
function readInstalledPackages(baseDir, vendorDir) {
|
|
457
|
+
const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
458
|
+
return Array.isArray(json) ? json : (json?.packages ?? [])
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// JSON `text` with each integer as PHP holds it: one past ±(2^53 − 1) a BigInt, as
|
|
462
|
+
// @preventive/lockfile reads one, so two values compare equal only where PHP's do.
|
|
463
|
+
const parseJsonExact = (text) => JSON.parse(text, (key, value, { source }) =>
|
|
464
|
+
(typeof value === 'number' && !Number.isSafeInteger(value) && /^-?\d+$/u.test(source) ? BigInt(source) : value))
|
|
465
|
+
|
|
466
|
+
// Where installed.json says Composer installed package `p`, baseDir-relative: its install-path,
|
|
467
|
+
// from vendor/composer (null for none, as of a metapackage), or <vendor-dir>/<name> where it gives
|
|
468
|
+
// none (Composer 1's); null outside baseDir.
|
|
469
|
+
function installedDir(baseDir, vendorDir, p) {
|
|
470
|
+
const path = p['install-path']
|
|
471
|
+
if (path === undefined) return projectRel(baseDir, resolve(baseDir, vendorDir, p.name))
|
|
472
|
+
return typeof path === 'string' ? projectRel(baseDir, resolve(baseDir, vendorDir, 'composer', path)) : null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Where Composer's library installer puts a package of the lockfile, baseDir-relative
|
|
476
|
+
// (LibraryInstaller::getInstallPath): <vendor-dir>/<name>, under its target-dir where it has one; a
|
|
477
|
+
// metapackage nowhere (null). One of a type an installer plugin takes (composer/installers') goes
|
|
478
|
+
// elsewhere, where its own composer.json's name finds it.
|
|
479
|
+
function lockedDir(baseDir, vendorDir, p) {
|
|
480
|
+
return p.type === 'metapackage' ? null : projectRel(baseDir, resolve(baseDir, vendorDir, p.name, p.targetDir ?? ''))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// What Composer adds to a package of the lockfile as it installs it, in installed.json.
|
|
484
|
+
const INSTALL_FIELDS = new Set(['version_normalized', 'installation-source', 'install-path'])
|
|
485
|
+
|
|
486
|
+
// That vendor/composer/installed.json (`installedText`, at `installedFile`) is the install of
|
|
487
|
+
// composer.lock (`lockText`, read by @preventive/lockfile already): as Composer 2 writes it,
|
|
488
|
+
// `{ packages, dev, dev-package-names }`; each package of the lockfile's `packages`, and of its
|
|
489
|
+
// `packages-dev` where it was installed with dev, there once and exactly as locked but for
|
|
490
|
+
// INSTALL_FIELDS, and nothing else; and the names of those of `packages-dev`, in lowercase and
|
|
491
|
+
// sorted, as dev-package-names. Throws where not. Returns the installed.json.
|
|
492
|
+
function assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir }) {
|
|
493
|
+
const differ = (detail) => new Error(`${installedFile} is not the install of composer.lock: ${detail}${vendorDir ? `; remove ${vendorDir} and run \`composer install\`` : ''}`)
|
|
494
|
+
let installed
|
|
495
|
+
try {
|
|
496
|
+
installed = parseJsonExact(installedText)
|
|
497
|
+
} catch {
|
|
498
|
+
throw new Error(`${installedFile}: not JSON`) // without the parser's message, which quotes the text
|
|
499
|
+
}
|
|
500
|
+
const shape = ['packages', 'dev', 'dev-package-names']
|
|
501
|
+
if (!isPlainObject(installed) || Object.keys(installed).length !== shape.length || !shape.every((key) => Object.hasOwn(installed, key)) ||
|
|
502
|
+
!Array.isArray(installed.packages) || typeof installed.dev !== 'boolean' || !Array.isArray(installed['dev-package-names'])) {
|
|
503
|
+
throw differ(`it is not as Composer 2 writes one, { ${shape.join(', ')} }`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const lock = parseJsonExact(lockText)
|
|
507
|
+
const locked = new Map()
|
|
508
|
+
for (const [key, dev] of [['packages', false], ['packages-dev', true]]) {
|
|
509
|
+
for (const spec of lock[key]) locked.set(spec.name, { spec, dev })
|
|
510
|
+
}
|
|
511
|
+
const seen = new Set()
|
|
512
|
+
for (const [index, entry] of installed.packages.entries()) {
|
|
513
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string') throw differ(`packages[${index}] is not a package`)
|
|
514
|
+
const { name } = entry
|
|
515
|
+
const want = locked.get(name)
|
|
516
|
+
if (want === undefined) throw differ(`${JSON.stringify(name)} is installed, and not locked`)
|
|
517
|
+
if (seen.has(name)) throw differ(`${name} is installed twice`)
|
|
518
|
+
seen.add(name)
|
|
519
|
+
if (want.dev && !installed.dev) throw differ(`${name} is installed, and locked in packages-dev, which an install without dev leaves out`)
|
|
520
|
+
const spec = Object.fromEntries(Object.entries(entry).filter(([key]) => !INSTALL_FIELDS.has(key)))
|
|
521
|
+
const key = [...new Set([...Object.keys(spec), ...Object.keys(want.spec)])].find((k) => !isDeepStrictEqual(spec[k], want.spec[k]))
|
|
522
|
+
if (key === 'version' && typeof spec.version === 'string') throw differ(`${name} is installed at ${JSON.stringify(spec.version)}, and locked at ${want.spec.version}`)
|
|
523
|
+
// `composer install` takes a package installed at the version and reference locked as it is,
|
|
524
|
+
// whatever else of it the lockfile changes.
|
|
525
|
+
if (key !== undefined) throw differ(`${name}'s ${JSON.stringify(key)} is not as locked`)
|
|
526
|
+
}
|
|
527
|
+
for (const [name, { dev }] of locked) {
|
|
528
|
+
if (!seen.has(name) && (installed.dev || !dev)) throw differ(`${name} is locked, and not installed`)
|
|
529
|
+
}
|
|
530
|
+
const devNames = [...locked].filter(([, { dev }]) => dev && installed.dev).map(([name]) => name.toLowerCase()).toSorted()
|
|
531
|
+
if (!isDeepStrictEqual(installed['dev-package-names'], devNames)) throw differ("its dev-package-names are not the names of the lockfile's packages-dev installed")
|
|
532
|
+
return installed
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// The Composer packages of the project at `baseDir`, `[{ name, version, dir, extra }]`, `dir` where
|
|
536
|
+
// the package is installed, baseDir-relative (null for nowhere in baseDir, as a metapackage). Of
|
|
537
|
+
// its composer.lock where there is one, read by @preventive/lockfile beside composer.json as
|
|
538
|
+
// `composer install` reads them -- one it refuses stops the build -- each package where Composer
|
|
539
|
+
// installs it (lockedDir), so no install is needed; else of vendor/composer/installed.json, as
|
|
540
|
+
// it is. Where both are, installed.json must be the lockfile's install (assertInstalledAsLocked):
|
|
541
|
+
// its packages, each where installed.json says.
|
|
542
|
+
export function loadComposerPackages(baseDir) {
|
|
543
|
+
const { vendorDir } = readComposerJson(baseDir)
|
|
544
|
+
const lockText = readUtf8OrNull(join(baseDir, 'composer.lock'), 'composer.lock')
|
|
545
|
+
if (lockText === null) {
|
|
546
|
+
return readInstalledPackages(baseDir, vendorDir)
|
|
547
|
+
.filter((p) => p?.name && p.version)
|
|
548
|
+
.map((p) => ({ name: p.name, version: p.version, dir: installedDir(baseDir, vendorDir, p), extra: p.extra }))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const composerJson = readUtf8OrNull(join(baseDir, 'composer.json'), 'composer.json')
|
|
552
|
+
const lock = nameErrors('composer.lock', () => parseComposerLock(lockText, composerJson === null ? {} : { composerJson }), [LockfileError])
|
|
553
|
+
const installedFile = toPosix(join(vendorDir, 'composer', 'installed.json'))
|
|
554
|
+
const installedText = readUtf8OrNull(join(baseDir, installedFile), installedFile)
|
|
555
|
+
const installed = installedText === null ? null : assertInstalledAsLocked(lockText, installedText, { installedFile, vendorDir })
|
|
556
|
+
const dirs = installed === null ? null : new Map(installed.packages.map((p) => [p.name, installedDir(baseDir, vendorDir, p)]))
|
|
557
|
+
return Object.values(lock.packages)
|
|
558
|
+
.filter((p) => dirs === null || dirs.has(p.name))
|
|
559
|
+
.map((p) => ({ name: p.name, version: p.version, dir: dirs === null ? lockedDir(baseDir, vendorDir, p) : dirs.get(p.name), extra: p.extra }))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Build the autoload config for `baseDir`, merging the root composer.json
|
|
563
|
+
// `autoload`(+dev) with the generated `vendor/composer/autoload_*` maps. All
|
|
564
|
+
// paths normalised to POSIX baseDir-relative (escapers dropped). Returns
|
|
565
|
+
// `{ psr4, psr0, classmap, files }`, or null when no Composer config is found.
|
|
566
|
+
export function loadComposerAutoload(baseDir) {
|
|
567
|
+
const { composerJson, vendorDir } = readComposerJson(baseDir)
|
|
568
|
+
|
|
569
|
+
const psr4 = new Map()
|
|
570
|
+
const psr0 = new Map()
|
|
571
|
+
const classmap = new Map()
|
|
572
|
+
const files = new Set()
|
|
556
573
|
let found = false
|
|
557
574
|
|
|
575
|
+
const mergeDirs = (map, prefix, dirs) => map.set(prefix, [...new Set([...(map.get(prefix) ?? []), ...dirs])])
|
|
558
576
|
const addPrefixDirs = (map, prefix, paths) => {
|
|
559
577
|
const list = Array.isArray(paths) ? paths : [paths]
|
|
560
578
|
const dirs = []
|
|
@@ -562,8 +580,7 @@ export function loadComposerAutoload(baseDir) {
|
|
|
562
580
|
const rel = normalizeProjectRel(baseDir, String(p))
|
|
563
581
|
if (rel !== null) dirs.push(rel)
|
|
564
582
|
}
|
|
565
|
-
|
|
566
|
-
map.set(prefix, [...new Set([...prev, ...dirs])])
|
|
583
|
+
mergeDirs(map, prefix, dirs)
|
|
567
584
|
}
|
|
568
585
|
|
|
569
586
|
// Root composer.json autoload (+ dev). PSR-4 keys keep their trailing '\'.
|
|
@@ -581,48 +598,35 @@ export function loadComposerAutoload(baseDir) {
|
|
|
581
598
|
// Generated maps. `$vendorDir` = <baseDir>/<vendorDir>, `$baseDir` = <baseDir>.
|
|
582
599
|
const composerGenDir = join(baseDir, vendorDir, 'composer')
|
|
583
600
|
const vars = { baseDir: resolve(baseDir), vendorDir: resolve(baseDir, vendorDir) }
|
|
584
|
-
const toRel = (abs) =>
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!existsSync(file)) return
|
|
601
|
+
const toRel = (abs) => projectRel(baseDir, abs) || null
|
|
602
|
+
// A generated map's entries; none when the file is absent.
|
|
603
|
+
const generated = (name, multi) => {
|
|
604
|
+
const file = join(composerGenDir, name)
|
|
605
|
+
if (!existsSync(file)) return new Map()
|
|
590
606
|
found = true
|
|
591
|
-
|
|
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
|
-
}
|
|
607
|
+
return parseGeneratedMap(readFileSync(file, 'utf8'), vars, multi)
|
|
596
608
|
}
|
|
597
|
-
|
|
598
|
-
|
|
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)
|
|
609
|
+
for (const [name, target] of [['autoload_psr4.php', psr4], ['autoload_namespaces.php', psr0]]) {
|
|
610
|
+
for (const [prefix, dirs] of generated(name, true)) {
|
|
611
|
+
mergeDirs(target, prefix, dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, '')))
|
|
606
612
|
}
|
|
607
613
|
}
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
614
|
+
for (const [fqcn, abs] of generated('autoload_classmap.php', false)) {
|
|
615
|
+
const rel = toRel(abs)
|
|
616
|
+
if (rel) classmap.set(fqcn.replace(/^\\+/u, ''), rel)
|
|
617
|
+
}
|
|
618
|
+
for (const [, abs] of generated('autoload_files.php', false)) {
|
|
619
|
+
const rel = toRel(abs)
|
|
620
|
+
if (rel) files.add(rel)
|
|
615
621
|
}
|
|
616
622
|
|
|
617
623
|
if (!found) return null
|
|
618
624
|
return { psr4, psr0, classmap, files: [...files] }
|
|
619
625
|
}
|
|
620
626
|
|
|
621
|
-
// Resolve a
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
// PSR-0 prefix. Only returns a path that actually exists on disk (so a PSR-4
|
|
625
|
-
// prefix match for a class whose file is absent doesn't get bundled as a hole).
|
|
627
|
+
// Resolve a FQCN to a baseDir-relative file via the autoload config, mirroring
|
|
628
|
+
// Composer's lookup order: classmap, then longest PSR-4 prefix, then PSR-0. Only
|
|
629
|
+
// returns a path that exists on disk (an absent prefix match isn't bundled as a hole).
|
|
626
630
|
export function resolveClassFile(fqcn, autoload, baseDir) {
|
|
627
631
|
if (!autoload || !fqcn) return null
|
|
628
632
|
const name = fqcn.replace(/^\\+/u, '')
|
|
@@ -657,43 +661,27 @@ export function resolveClassFile(fqcn, autoload, baseDir) {
|
|
|
657
661
|
return null
|
|
658
662
|
}
|
|
659
663
|
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// back to shorter ones (and the `""` fallback) when a file isn't present.
|
|
664
|
+
// [prefix, dirs] entries whose prefix is a prefix of `name`, longest first --
|
|
665
|
+
// prefer the most specific mapping, fall back to shorter (and the `""` fallback).
|
|
663
666
|
function matchingPrefixes(map, name) {
|
|
664
667
|
return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
|
|
665
668
|
}
|
|
666
669
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// authoritative source, since a package's own composer.json usually omits
|
|
671
|
-
// `version` (Composer derives it at install time). Handles both the Composer 2
|
|
672
|
-
// shape ({ packages: [...] }) and the Composer 1 flat array. Returns maps from
|
|
673
|
-
// install dir (baseDir-relative) and from package name to version.
|
|
674
|
-
function loadInstalledVersions(baseDir, vendorDir) {
|
|
675
|
-
const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
|
|
670
|
+
// The versions of the Composer `packages` (loadComposerPackages: the authoritative source; a
|
|
671
|
+
// package's own composer.json usually omits `version`), by where each is installed and by name.
|
|
672
|
+
function packageVersions(packages) {
|
|
676
673
|
const byDir = new Map()
|
|
677
674
|
const byName = new Map()
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
if (
|
|
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)
|
|
675
|
+
for (const { name, version, dir } of packages) {
|
|
676
|
+
byName.set(name, version)
|
|
677
|
+
if (dir) byDir.set(dir, version)
|
|
688
678
|
}
|
|
689
679
|
return { byDir, byName }
|
|
690
680
|
}
|
|
691
681
|
|
|
692
|
-
// Walk up from a bundled file to the nearest composer.json
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
// falling back to composer.json's own `version`, then `fallbackVersion`.
|
|
696
|
-
// Returns null when no named composer.json sits at or above the file.
|
|
682
|
+
// Walk up from a bundled file to the nearest named composer.json, returning
|
|
683
|
+
// { pkgDir, name, version } (pkgDir "." for the root; version from composer.lock or
|
|
684
|
+
// installed.json by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
|
|
697
685
|
function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
698
686
|
let dir = dirname(fileRelPath)
|
|
699
687
|
for (;;) {
|
|
@@ -709,17 +697,12 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
|
|
|
709
697
|
}
|
|
710
698
|
}
|
|
711
699
|
|
|
712
|
-
// Group bundled PHP sources into per-package buckets keyed by the
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
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)
|
|
700
|
+
// Group bundled PHP sources into per-package buckets keyed by the nearest
|
|
701
|
+
// composer.json's directory (vendor deps get their own; workspace files and
|
|
702
|
+
// orphans -> the root "." bucket with the placeholder identity). Returns a
|
|
703
|
+
// Map<dir, { name, version, files }>. `packages`: loadComposerPackages(baseDir).
|
|
704
|
+
export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion, packages = loadComposerPackages(baseDir)) {
|
|
705
|
+
const installed = packageVersions(packages)
|
|
723
706
|
|
|
724
707
|
const modules = new Map()
|
|
725
708
|
const ensureBucket = (dir, name, version, ecosystem) => {
|
|
@@ -735,9 +718,8 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
735
718
|
const pkg = findComposerPackage(baseDir, path, installed, fallbackVersion)
|
|
736
719
|
if (pkg) {
|
|
737
720
|
const rel = pkg.pkgDir === '.' ? path : path.slice(pkg.pkgDir.length + 1)
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// bucket is the workspace's own code and stays ecosystem-less.
|
|
721
|
+
// Below the root package = an installed Composer dependency, tagged
|
|
722
|
+
// `composer`; the root "." bucket is workspace code and stays ecosystem-less.
|
|
741
723
|
const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
|
|
742
724
|
ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
|
|
743
725
|
} else {
|
|
@@ -747,29 +729,20 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
|
|
|
747
729
|
return modules
|
|
748
730
|
}
|
|
749
731
|
|
|
750
|
-
// Laravel auto-registers service providers
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
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) {
|
|
732
|
+
// Laravel auto-registers service providers nothing references statically. Returns
|
|
733
|
+
// discoverable provider files (from composer.json + the Composer `packages`'
|
|
734
|
+
// `extra.laravel.providers`, autoload-resolved) plus bootstrap/providers.php as
|
|
735
|
+
// extra collection roots. Best-effort: unresolvable classes skipped.
|
|
736
|
+
// `packages`: loadComposerPackages(baseDir).
|
|
737
|
+
export function loadLaravelProviderFiles(baseDir, autoload, packages = loadComposerPackages(baseDir)) {
|
|
761
738
|
const files = new Set()
|
|
762
739
|
if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
|
|
763
740
|
if (!autoload) return [...files]
|
|
764
741
|
|
|
765
|
-
const composerJson =
|
|
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
|
-
|
|
742
|
+
const { composerJson } = readComposerJson(baseDir)
|
|
770
743
|
const fqcns = new Set(composerJson?.extra?.laravel?.providers ?? [])
|
|
771
|
-
for (const pkg of
|
|
772
|
-
for (const fqcn of pkg
|
|
744
|
+
for (const pkg of packages) {
|
|
745
|
+
for (const fqcn of pkg.extra?.laravel?.providers ?? []) fqcns.add(fqcn)
|
|
773
746
|
}
|
|
774
747
|
for (const fqcn of fqcns) {
|
|
775
748
|
const file = resolveClassFile(fqcn, autoload, baseDir)
|
|
@@ -778,36 +751,28 @@ export function loadLaravelProviderFiles(baseDir, autoload) {
|
|
|
778
751
|
return [...files]
|
|
779
752
|
}
|
|
780
753
|
|
|
781
|
-
//
|
|
782
|
-
|
|
754
|
+
// Names excluded from class-reference scanning: pseudo-types, scalar/special
|
|
755
|
+
// types, and keywords that can sit before `$var`/`::` or after `new` and would
|
|
756
|
+
// otherwise be mistaken for a class reference (`return $x`, `new class`, …).
|
|
783
757
|
const RESERVED = new Set([
|
|
784
|
-
// pseudo-types / class-context keywords
|
|
785
758
|
'self', 'static', 'parent', 'this',
|
|
786
|
-
// scalar + special type names
|
|
787
759
|
'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean', 'void',
|
|
788
760
|
'array', 'callable', 'iterable', 'object', 'mixed', 'null', 'false', 'true',
|
|
789
761
|
'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
762
|
'return', 'echo', 'print', 'clone', 'yield', 'throw', 'global', 'and', 'or', 'xor',
|
|
794
763
|
'as', 'else', 'elseif', 'do', 'class', 'fn', 'match', 'enddeclare', 'endforeach',
|
|
795
764
|
'endif', 'endswitch', 'endwhile', 'endfor',
|
|
796
765
|
])
|
|
797
766
|
|
|
798
|
-
// Blank
|
|
799
|
-
// reference scans don't trip over
|
|
800
|
-
// inside them. Unlike `maskCommentsAndHeredocs`, this also removes string
|
|
801
|
-
// contents -- class references never live in strings.
|
|
767
|
+
// Blank heredocs, comments AND string literals (unlike maskCommentsAndHeredocs,
|
|
768
|
+
// which keeps strings) so class-reference scans don't trip over text inside them.
|
|
802
769
|
function stripPhp(content) {
|
|
803
770
|
return maskNonCode(content, false)
|
|
804
771
|
}
|
|
805
772
|
|
|
806
|
-
// Add the FQCN for a class reference
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
// through it; otherwise an unqualified/qualified name is relative to the
|
|
810
|
-
// current namespace.
|
|
773
|
+
// Add the FQCN for a class reference `raw` given namespace `ns` and the `use`-alias
|
|
774
|
+
// map, mirroring PHP name resolution: leading `\` = fully-qualified; a first
|
|
775
|
+
// segment matching an alias expands through it; else relative to `ns`.
|
|
811
776
|
function addRef(set, raw, ns, aliasMap) {
|
|
812
777
|
const n = raw.trim().replace(/^\?/u, '') // drop a nullable-type marker (`?Foo`)
|
|
813
778
|
if (!n || !/^\\?[A-Za-z_][\w\\]*$/u.test(n)) return
|
|
@@ -827,10 +792,9 @@ function addRef(set, raw, ns, aliasMap) {
|
|
|
827
792
|
}
|
|
828
793
|
}
|
|
829
794
|
|
|
830
|
-
// Parse
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
// the symbol as written (sans alias) and `short` is the alias or last segment.
|
|
795
|
+
// Parse a `use` body (between `use` and `;`) into entries, handling comma lists
|
|
796
|
+
// and group syntax (`Pre\{A, B as C}`). Returns `[{ name, short }]` (short = the
|
|
797
|
+
// alias or last segment).
|
|
834
798
|
function parseUseBody(body) {
|
|
835
799
|
const grouped = body.match(/^([\w\\]+\\)\{([\s\S]*)\}$/u)
|
|
836
800
|
const entries = grouped
|
|
@@ -846,34 +810,27 @@ function parseUseBody(body) {
|
|
|
846
810
|
return out
|
|
847
811
|
}
|
|
848
812
|
|
|
849
|
-
// Position of the first class/interface/trait/enum declaration
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
// does load the trait). `::class` and member accesses are excluded via the
|
|
854
|
-
// lookbehind. Returns Infinity for a file with no class-like declaration.
|
|
855
|
-
// Cheaper and more robust than brace-matching -- no nested-brace bookkeeping,
|
|
856
|
-
// and heredocs are already stripped out.
|
|
813
|
+
// Position of the first class/interface/trait/enum declaration -- the end of the
|
|
814
|
+
// import region (a `use` before it is an alias import; after it, an in-body trait
|
|
815
|
+
// use that loads the trait). `::class`/member accesses excluded via the
|
|
816
|
+
// lookbehind. Infinity when there's no class-like declaration.
|
|
857
817
|
function firstDeclPos(stripped) {
|
|
858
818
|
const m = stripped.match(/(?<![:\w$\\>])\b(?:class|interface|trait|enum)\b/iu)
|
|
859
819
|
return m ? m.index : Infinity
|
|
860
820
|
}
|
|
861
821
|
|
|
862
|
-
// Collect the
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
// / parameter+return type hints / fully-qualified names, plus in-body
|
|
867
|
-
// `use Trait;` (which does load the trait).
|
|
822
|
+
// Collect the FQCNs a PHP file actually depends on. A `use` import only aliases
|
|
823
|
+
// and loads nothing, so it is NOT a dependency; only real reference sites pull a
|
|
824
|
+
// file in (new/extends/implements/instanceof/catch/`Foo::bar`/attributes/type
|
|
825
|
+
// hints/FQNs, plus in-body `use Trait;`).
|
|
868
826
|
export function phpClassDependencies(content) {
|
|
869
827
|
const stripped = stripPhp(content)
|
|
870
828
|
const nsMatch = stripped.match(/\bnamespace\s+([\w\\]+)/u)
|
|
871
829
|
const ns = nsMatch ? nsMatch[1].replace(/^\\+|\\+$/gu, '') : ''
|
|
872
830
|
const set = new Set()
|
|
873
831
|
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
// count as references.
|
|
832
|
+
// `use` before the first declaration builds the alias map; a `use` after it is
|
|
833
|
+
// an in-body trait use, counted as a reference.
|
|
877
834
|
const declAt = firstDeclPos(stripped)
|
|
878
835
|
const aliasMap = new Map()
|
|
879
836
|
const traitRefs = []
|
|
@@ -904,20 +861,16 @@ export function phpClassDependencies(content) {
|
|
|
904
861
|
if (impl) for (const nm of impl[1].split(',')) add(nm)
|
|
905
862
|
}
|
|
906
863
|
|
|
907
|
-
// catch (A | B $e): capture the type list up to
|
|
908
|
-
//
|
|
909
|
-
// long blanked run sits inside the parens with no `$var`.
|
|
864
|
+
// catch (A | B $e): capture the type list up to `$var`. The lazy `[^)]*?` (no
|
|
865
|
+
// second whitespace quantifier) keeps matching linear on adversarial input.
|
|
910
866
|
for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
|
|
911
867
|
for (const nm of m[1].split('|')) add(nm)
|
|
912
868
|
}
|
|
913
869
|
|
|
914
|
-
// parameter
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
// quantifier is unbounded (the inner `\s*` is gated behind a required `[&.]`),
|
|
919
|
-
// which avoids catastrophic backtracking when a token is followed by a long
|
|
920
|
-
// blanked run (comment/heredoc/string) with no `$var` after it.
|
|
870
|
+
// parameter/property type hints: `Type $var`, incl. `?Type`, variadic, by-ref,
|
|
871
|
+
// and union/intersection `A|B`/`A&B` (each member counts). Only one whitespace
|
|
872
|
+
// quantifier is unbounded (inner `\s*` gated behind `[&.]`) to avoid
|
|
873
|
+
// catastrophic backtracking against adversarial input.
|
|
921
874
|
for (const m of stripped.matchAll(/(?<![\w$\\])([?\w\\|&]+)\s+(?:[&.]+\s*)?\$\w+/giu)) {
|
|
922
875
|
for (const nm of m[1].split(/[|&]/u)) add(nm)
|
|
923
876
|
}
|
|
@@ -929,11 +882,8 @@ export function phpClassDependencies(content) {
|
|
|
929
882
|
return set
|
|
930
883
|
}
|
|
931
884
|
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
// Resolve every autoloaded class a file references to a baseDir-relative file
|
|
935
|
-
// path that exists on disk and is already part of `sources`. Best-effort:
|
|
936
|
-
// unresolvable references (builtins, extensions, dynamic) are skipped.
|
|
885
|
+
// Resolve each autoloaded class a file references to a baseDir-relative file that
|
|
886
|
+
// exists and is in `sources`. Best-effort: unresolvable refs skipped.
|
|
937
887
|
function autoloadEdges(content, autoload, baseDir, sources) {
|
|
938
888
|
const edges = new Map()
|
|
939
889
|
if (!autoload) return edges
|
|
@@ -944,12 +894,10 @@ function autoloadEdges(content, autoload, baseDir, sources) {
|
|
|
944
894
|
return edges
|
|
945
895
|
}
|
|
946
896
|
|
|
947
|
-
// Build
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
// `
|
|
951
|
-
// `missing`. As a final include fallback, a specifier that matches a stored
|
|
952
|
-
// key verbatim is accepted.
|
|
897
|
+
// Build `{ sources, resolutions, missing }` from already-loaded PHP sources.
|
|
898
|
+
// Explicit includes are strict (unresolvable -> `missing`); Composer-autoloaded
|
|
899
|
+
// class refs (when `autoload` is given) are best-effort and never added to
|
|
900
|
+
// `missing`. Fallback: a specifier matching a stored key verbatim is accepted.
|
|
953
901
|
export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
|
|
954
902
|
const resolutions = new Map()
|
|
955
903
|
const missing = []
|
|
@@ -974,10 +922,9 @@ export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
|
|
|
974
922
|
return { sources, resolutions, missing }
|
|
975
923
|
}
|
|
976
924
|
|
|
977
|
-
// Walk the filesystem
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
// in parallel; the next wave depends on what the previous one referenced.
|
|
925
|
+
// Walk the filesystem from `entries` (plus Composer `files` autoload entries),
|
|
926
|
+
// following resolved includes and autoloaded class refs, reading each file once.
|
|
927
|
+
// Same-wave files are read in parallel; the next wave depends on the previous.
|
|
981
928
|
export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = null } = {}) {
|
|
982
929
|
const sources = new Map()
|
|
983
930
|
const realBase = realpathSync(baseDir)
|
|
@@ -985,12 +932,14 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
985
932
|
const readOne = async (relPath) => {
|
|
986
933
|
try {
|
|
987
934
|
assertRealPathWithinBase(realBase, baseDir, relPath)
|
|
988
|
-
|
|
935
|
+
const bytes = await readFile(join(baseDir, relPath))
|
|
936
|
+
// One that isn't UTF-8 is refused rather than carried with U+FFFD in it.
|
|
937
|
+
if (!isUtf8(bytes)) throw new Error(`PHP source is not valid UTF-8: ${relPath}`)
|
|
938
|
+
return [relPath, bytes.toString('utf8')]
|
|
989
939
|
} catch (err) {
|
|
990
|
-
// ENOENT
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
// unresolved edge is surfaced later by buildPhpTree for real includes.
|
|
940
|
+
// ENOENT (no file) / EISDIR (specifier resolved to a directory): not a
|
|
941
|
+
// loadable file -- warn and skip rather than crash (buildPhpTree surfaces
|
|
942
|
+
// the unresolved edge for real includes).
|
|
994
943
|
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
|
|
995
944
|
console.warn(`[loader.php] Missing import: ${relPath}`)
|
|
996
945
|
return null
|
|
@@ -1002,9 +951,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1002
951
|
const processWave = async (wave) => {
|
|
1003
952
|
const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
|
|
1004
953
|
if (toLoad.length === 0) return
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
// Promise.all would open every file at once and crash with EMFILE.
|
|
954
|
+
// Bounded batches: a wave can be huge (a dynamic include's directory glob),
|
|
955
|
+
// and an unbounded Promise.all would open every file at once and crash EMFILE.
|
|
1008
956
|
const reads = []
|
|
1009
957
|
for (let k = 0; k < toLoad.length; k += READ_CONCURRENCY) {
|
|
1010
958
|
// eslint-disable-next-line no-await-in-loop -- batches are intentionally sequential to cap open files
|
|
@@ -1015,7 +963,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1015
963
|
if (!entry) continue
|
|
1016
964
|
const [relPath, content] = entry
|
|
1017
965
|
sources.set(relPath, content)
|
|
1018
|
-
|
|
966
|
+
const includes = scanIncludes(content)
|
|
967
|
+
for (const spec of includeSpecs(includes, 'file')) {
|
|
1019
968
|
const resolved = resolvePhpImport(spec, relPath, { baseDir })
|
|
1020
969
|
if (resolved) {
|
|
1021
970
|
if (!sources.has(resolved)) next.push(resolved)
|
|
@@ -1023,20 +972,17 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1023
972
|
console.warn(`[loader.php] Missing import: ${spec} from ${relPath}`)
|
|
1024
973
|
}
|
|
1025
974
|
}
|
|
1026
|
-
// Dynamic includes with a static
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
for (const dirSpec of extractPhpImportDirs(content)) {
|
|
975
|
+
// Dynamic includes with a static dir prefix: the exact file is known only
|
|
976
|
+
// at runtime, so bundle every `.php` file in that directory as a candidate.
|
|
977
|
+
for (const dirSpec of includeSpecs(includes, 'dir')) {
|
|
1030
978
|
const dir = resolvePhpDir(dirSpec, relPath, baseDir)
|
|
1031
979
|
if (!dir) continue
|
|
1032
980
|
for (const file of listPhpFiles(baseDir, dir)) {
|
|
1033
981
|
if (!sources.has(file)) next.push(file)
|
|
1034
982
|
}
|
|
1035
983
|
}
|
|
1036
|
-
// `.php` path literals
|
|
1037
|
-
//
|
|
1038
|
-
// helper refs (`base_path(...)`) against the project root. Best-effort:
|
|
1039
|
-
// bundle only if they resolve to a file that exists.
|
|
984
|
+
// `.php` path literals outside a require (route/config files passed to a
|
|
985
|
+
// framework). Best-effort: bundle only if they resolve to an existing file.
|
|
1040
986
|
for (const { spec, anchor } of extractPhpPathRefs(content)) {
|
|
1041
987
|
const resolved = anchor === 'root'
|
|
1042
988
|
? normalizeProjectRel(baseDir, spec)
|
|
@@ -1051,8 +997,7 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
|
|
|
1051
997
|
await processWave(next)
|
|
1052
998
|
}
|
|
1053
999
|
|
|
1054
|
-
// `files` autoload entries
|
|
1055
|
-
// are roots of the graph alongside the caller's entries.
|
|
1000
|
+
// Composer `files` autoload entries load unconditionally, so they're graph roots too.
|
|
1056
1001
|
await processWave([...entries, ...(autoload?.files ?? [])])
|
|
1057
1002
|
return sources
|
|
1058
1003
|
}
|