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