@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.
Files changed (48) hide show
  1. package/README.md +32 -8
  2. package/bin/stasis.js +389 -214
  3. package/package.json +52 -12
  4. package/src/audit-corrections.js +96 -0
  5. package/src/audit.js +289 -64
  6. package/src/babel-worker.cjs +65 -0
  7. package/src/babel.js +131 -0
  8. package/src/bundle-from-lockfile.js +14 -22
  9. package/src/bundle-reader.js +77 -0
  10. package/src/cmd/build.js +96 -128
  11. package/src/cmd/bundle.js +1121 -654
  12. package/src/cmd/diff.js +2 -19
  13. package/src/cmd/github-bundle.js +39 -0
  14. package/src/cmd/output.js +36 -0
  15. package/src/cmd/sbom.js +7 -19
  16. package/src/diff.js +80 -129
  17. package/src/installed-commits.js +182 -0
  18. package/src/json-stream.js +283 -0
  19. package/src/loader-mock.js +8 -15
  20. package/src/loaders/bash.js +49 -116
  21. package/src/loaders/cargo.js +1716 -0
  22. package/src/loaders/foundry.js +923 -0
  23. package/src/loaders/paths.js +33 -0
  24. package/src/loaders/php.js +375 -402
  25. package/src/loaders/rust.js +3254 -242
  26. package/src/loaders/solidity-ownership.js +531 -0
  27. package/src/loaders/solidity.js +412 -162
  28. package/src/loaders/toml.js +68 -0
  29. package/src/metro-resolver.js +150 -0
  30. package/src/metro-transformer.js +3 -2
  31. package/src/mock.js +57 -165
  32. package/src/parse.js +4 -16
  33. package/src/resolve-fields.js +370 -0
  34. package/src/resolve-node.js +419 -0
  35. package/src/resolve-typescript.js +541 -0
  36. package/src/rollup.js +5 -0
  37. package/src/sbom.js +120 -97
  38. package/src/scan.js +348 -180
  39. package/src/vfs-bundle/entries.js +144 -0
  40. package/src/vfs-bundle/github.js +202 -0
  41. package/src/vfs-bundle/prisma.js +439 -0
  42. package/src/vfs-bundle/soldeer.js +125 -0
  43. package/src/vfs-bundle/tree.js +647 -0
  44. package/src/vfs-bundle.js +80 -0
  45. package/src/why.js +434 -0
  46. package/src/apis/npm/index.js +0 -36
  47. package/src/apis/npm/semver.cjs +0 -44
  48. package/src/cmd/extract.js +0 -193
@@ -1,52 +1,28 @@
1
- // PHP include-graph + Composer-autoload loader, modelled on the Solidity loader
2
- // in this directory.
3
- //
4
- // Produces a `{ sources, resolutions, missing }` triple by statically scanning
5
- // PHP source. Two dependency mechanisms are followed, neither of which executes
6
- // any PHP:
7
- //
8
- // 1. Explicit includes -- `require` / `require_once` / `include` /
9
- // `include_once` with a string-literal path. Three argument shapes are
10
- // understood:
11
- // require_once __DIR__ . '/lib/Foo.php'; // dir-relative (modern)
12
- // include dirname(__FILE__) . '/Bar.php'; // dir-relative (legacy)
13
- // require 'helpers.php'; // bare / include_path
14
- // These are treated as *strict*: an unresolved include is reported in
15
- // `missing` and makes `buildPhpBundle` refuse to emit a bundle, because a
16
- // literal require with no target is a real, load-time error.
17
- //
18
- // 2. Composer autoloading -- classes pulled in lazily by the autoloader
19
- // `require 'vendor/autoload.php'` registers. Those class files are never
20
- // named in a literal `require`, so the include scan alone misses them.
21
- // When a `composer.json` (or generated `vendor/composer/autoload_*.php`
22
- // map) is present, we read the PSR-4 / PSR-0 / classmap / files autoload
23
- // config, extract the classes each file actually *references* (a `use`
24
- // statement only creates an alias and loads nothing, so it is not itself a
25
- // dependency -- references come from `new`/`extends`/`implements`/
26
- // `instanceof`/`catch`, `Foo::bar`, type hints, attributes, fully-qualified
27
- // names, and in-body trait `use`), resolve each to a file the same way
28
- // Composer's ClassLoader would, and follow it. This is
29
- // deliberately *best-effort*: PHP class loading is dynamic in the general
30
- // case (`new $class`, reflection), and most unresolved references are
31
- // built-in/global classes (`\Exception`, `\DateTime`) or extension classes
32
- // that live in no autoload map -- so an unresolved class reference is
33
- // silently skipped rather than treated as a missing dependency.
34
-
35
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
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
- import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
40
-
41
- // Max files read concurrently per wave, to avoid EMFILE on large directory globs.
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
- // --- Shared lexing helpers ---------------------------------------------------
45
-
46
- // End index (exclusive) of the heredoc/nowdoc opening at `start` (`<<<`), or
47
- // `start` itself if it isn't a valid opener. Consumes to EOF when unterminated.
48
- // Recognises the indented closing label (PHP 7.3+) and the leading quote of
49
- // nowdoc/quoted-heredoc labels.
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
- // Single linear pass that blanks everything which is not executable PHP code:
80
- // `//`/`#` line comments, `/* */` block comments, heredocs/nowdocs, and (unless
81
- // `keepStrings`) string literals. Each consumed character becomes a space
82
- // (newlines preserved), so the output is the same length as the input and byte
83
- // offsets stay aligned. Strings are scanned in the same loop as comments, so a
84
- // `//`/`#` inside a string is never mistaken for a comment, and `#[` (a PHP 8
85
- // attribute) is left intact. A hand-written scanner (rather than one big regex)
86
- // keeps this strictly linear -- immune to the catastrophic backtracking a regex
87
- // suffers on unterminated strings/heredocs in adversarial input.
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
- // Comments/heredocs blanked, string literals kept (include paths live in
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 code for string literals, returning each as { start, end, value } with
132
- // PHP single-quote-style unescaping (only `\\` and the closing quote are
133
- // escapes; every other backslash is literal -- good enough for path values).
134
- // Knowing the real boundaries means quote pairing is never guessed: an include
135
- // keyword sitting inside a string is recognisable, and a string can't be
136
- // mis-paired with a quote belonging to the next concatenated string.
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
- // --- Explicit includes -------------------------------------------------------
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
- // Locates an include keyword. The leading lookbehind keeps method calls
169
- // (`$x->require(...)`), scope resolutions (`Foo::include(...)`) and longer
170
- // identifiers from matching; the trailing `\b` stops `require` from matching
171
- // inside `requireConfig`. Matches inside string literals are filtered out by
172
- // the caller using the real string spans.
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 at a position (sticky): `__DIR__` or `dirname(__FILE__)`
176
- // (both = the file's own directory), or `dirname(__DIR__[, N])` (N parent levels
177
- // up -- the idiom for locating bootstrap/vendor/config relative to a file).
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 that resolve a string argument relative to the project
183
- // root (or a fixed subdirectory). `base_path('routes/web.php')`,
184
- // `config_path('app.php')`, … name real PHP files that the framework require()s.
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 directory: 0 for `__DIR__`/`dirname(__FILE__)`, N for
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
- // Normalise an include path to a specifier. Dir-relative paths (`__DIR__ . …`)
219
- // get a `./` prefix and have the leading separator stripped; an empty
220
- // dir-relative path means "the file's own directory" (`.`).
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.startsWith('.') ? rel : `./${rel}`
190
+ return isDotRelative(rel) ? rel : `./${rel}`
226
191
  }
227
192
 
228
- // Parse an include/path argument starting at `start`: an optional directory
229
- // anchor (`__DIR__`/`dirname(...)`), then a concatenation chain of string
230
- // literals. Consecutive *static* literals are concatenated (`__DIR__ . '/a' .
231
- // '/b.php'` -> one static path); the chain becomes `dynamic` at the first
232
- // variable/function/constant or `"…$interp…"`. Parent levels from the anchor
233
- // (`dirname(__DIR__, N)`) are folded in as leading `/..`. Returns null when
234
- // there's neither an anchor nor any static string. The accumulated `prefix` is
235
- // the static path up to the first dynamic piece.
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 // anchor not concatenated with a path
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 } // variable/constant/function -> dynamic
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 } // concatenated -> keep going
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 PHP `require`/`include` statements, classifying each as a concrete file
269
- // or -- when the path is built dynamically but has a static directory prefix --
270
- // a directory whose `.php` files should all be bundled (any could be the runtime
271
- // target, e.g. Laravel's `require __DIR__ . "/.../components/$view.php"`). Works
272
- // on comment/heredoc-masked code and real string-literal boundaries (never regex
273
- // quote-pairing); keyword matches inside a string are skipped.
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 directory prefix (up to the last
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) // `dirname(__DIR__) . $x` -> parent dir
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 only after
304
- // `require`/`include`). Frameworks pass file paths as arguments for code that
305
- // `require`s them internally -- e.g. Laravel's `->withRouting(web: __DIR__ .
306
- // '/../routes/web.php', …)`. These are best-effort: bundled only if they resolve
307
- // to a file that exists (see collectPhpFilesFromDisk), so a path that's just a
308
- // string and never loaded does no harm.
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'` (anchor: resolve against the file).
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')` etc. (anchor: resolve
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).filter((e) => e.kind === 'file').map((e) => e.spec)
291
+ return includeSpecs(scanIncludes(content), 'file')
341
292
  }
342
293
 
343
- // Directory specifiers for dynamic includes whose path has a static directory
344
- // prefix; every `.php` file directly in such a directory is a candidate target.
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).filter((e) => e.kind === 'dir').map((e) => e.spec)
297
+ return includeSpecs(scanIncludes(content), 'dir')
347
298
  }
348
299
 
349
- // `.php` path literals used outside a `require`/`include` -- dir-anchored
350
- // (`__DIR__ . '/x.php'`, `anchor: 'file'`) or via a Laravel path helper
351
- // (`base_path('routes/x.php')`, `anchor: 'root'`). Frameworks pass these to code
352
- // that require()s them. De-duplicated; best-effort (bundled only if they exist).
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
- // Apply `segments` (already split on '/') on top of `fromFile`'s directory,
364
- // collapsing '.'/'' and resolving '..'. Returns a baseDir-relative POSIX path,
365
- // or null when traversal escapes above the project root (rather than clamping).
366
- function applyToDir(fromFile, segments) {
367
- const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
368
- const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
369
- const resolved = []
370
- for (const part of parts) {
371
- if (part === '.' || part === '') continue
372
- if (part === '..') {
373
- if (resolved.length === 0) return null
374
- resolved.pop()
375
- } else {
376
- resolved.push(part)
377
- }
378
- }
379
- return resolved.join('/')
380
- }
312
+ const isFileOnDisk = (baseDir, rel) => isFile(join(baseDir, rel))
313
+ const isDirOnDisk = (baseDir, rel) => isDir(join(baseDir, rel))
381
314
 
382
- function isFileOnDisk(baseDir, rel) {
383
- try {
384
- return statSync(join(baseDir, rel)).isFile()
385
- } catch {
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
- function isDirOnDisk(baseDir, rel) {
391
- try {
392
- return statSync(join(baseDir, rel)).isDirectory()
393
- } catch {
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 (from a dynamic include's static prefix) to an
399
- // existing baseDir-relative directory, or null. `./`/`../` resolve against the
400
- // including file; a bare spec tries file-relative then project-relative.
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
- if (spec.startsWith('.')) {
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 = relative(baseDir, resolve(baseDir, spec)).split(/[\\/]/u).join('/')
410
- if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isDirOnDisk(baseDir, 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), as baseDir-relative
418
- // paths. Non-recursive: a dynamic include like `.../$view.php` targets a file in
419
- // the directory itself, and recursing could pull in an unbounded subtree.
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
- // - `./x` / `../x` resolve against the including file's directory, exactly
432
- // like Solidity relative imports; escaping the root returns null.
433
- // - a bare specifier (`lib/Foo.php`, `helpers.php`) is ambiguous under PHP's
434
- // include_path, so it is disambiguated by what's actually on disk: a file
435
- // next to the including file wins, otherwise a project-root-relative match.
436
- // Both require `baseDir`; absolute specifiers are rejected outright.
437
- // Returns null when no strategy resolves the include.
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
- if (specifier.startsWith('.')) {
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 = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
448
- if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isFileOnDisk(baseDir, 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
- // --- Composer autoload config ------------------------------------------------
457
-
458
- // Normalise an autoload directory/file path that is relative to the project
459
- // root to a POSIX baseDir-relative path, or null when it escapes the root.
460
- // Resolves against baseDir and re-derives the relative path so that *interior*
461
- // `..` segments (e.g. `src/foo/../../../secret.php`) that escape the root are
462
- // rejected -- a leading-`..`/absolute check alone would let those through and
463
- // read files outside the project into the bundle.
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
- const rel = relative(baseDir, resolve(baseDir, p.replace(/\\/gu, '/'))).split(/[\\/]/u).join('/')
466
- if (rel === '') return ''
467
- if (rel.startsWith('..') || isAbsolute(rel)) return null
468
- return rel
385
+ refuseBackslash(p)
386
+ return projectRel(baseDir, resolve(baseDir, p))
469
387
  }
470
388
 
471
- // Evaluate a generated-autoload path expression like `$baseDir . '/src'` or
472
- // `$vendorDir . '/a/b'` or a bare quoted string, given the absolute values of
473
- // the `$baseDir` / `$vendorDir` variables. Returns the absolute path string,
474
- // or null when the expression contains anything we don't understand (a
475
- // function call, an unknown variable) so we skip it rather than guess.
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 once exec() finally returns null,
484
- // so track how far we actually parsed instead of reading lastIndex after
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 one of Composer's generated `vendor/composer/autoload_*.php` maps.
502
- // They are `@generated`, one entry per line:
503
- // 'Prefix\\' => array($baseDir . '/src'), // multi=true (psr-4/psr-0)
504
- // 'Fully\\Qualified\\Class' => $baseDir . '/X.php' // multi=false (classmap/files)
505
- // Returns a Map of key -> (dirs[] when multi, file when not). PHP single-quote
506
- // escapes (`\\`, `\'`) in keys are unfolded to real characters.
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
- // Build the autoload resolution config for a project rooted at `baseDir`.
539
- // Merges, when present:
540
- // - the root composer.json `autoload` + `autoload-dev` (psr-4/psr-0/files;
541
- // paths are relative to the project root)
542
- // - the generated `vendor/composer/autoload_{psr4,namespaces,classmap,files}.php`
543
- // maps (cover the root AND every installed dependency; the authoritative
544
- // source once `composer install`/`dump-autoload` has run)
545
- // All directory/file paths are normalised to POSIX baseDir-relative; anything
546
- // escaping the root is dropped (it can't be bundled). Returns
547
- // `{ psr4, psr0, classmap, files }` or null when no Composer config is found.
548
- export function loadComposerAutoload(baseDir) {
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 psr4 = new Map() // prefix (trailing '\') -> dirs[]
553
- const psr0 = new Map() // prefix -> dirs[]
554
- const classmap = new Map() // FQCN -> file
555
- const files = new Set() // baseDir-relative file
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
- const prev = map.get(prefix) ?? []
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
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
586
- return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) ? rel : null
587
- }
588
- const mergePrefixMap = (file, target) => {
589
- if (!existsSync(file)) return
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
- for (const [prefix, dirs] of parseGeneratedMap(readFileSync(file, 'utf8'), vars, true)) {
592
- const rels = dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, ''))
593
- const prev = target.get(prefix) ?? []
594
- target.set(prefix, [...new Set([...prev, ...rels])])
595
- }
633
+ return parseGeneratedMap(readFileSync(file, 'utf8'), vars, multi)
596
634
  }
597
- mergePrefixMap(join(composerGenDir, 'autoload_psr4.php'), psr4)
598
- mergePrefixMap(join(composerGenDir, 'autoload_namespaces.php'), psr0)
599
-
600
- const classmapFile = join(composerGenDir, 'autoload_classmap.php')
601
- if (existsSync(classmapFile)) {
602
- found = true
603
- for (const [fqcn, abs] of parseGeneratedMap(readFileSync(classmapFile, 'utf8'), vars, false)) {
604
- const rel = toRel(abs)
605
- if (rel) classmap.set(fqcn.replace(/^\\+/u, ''), rel)
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 filesFile = join(composerGenDir, 'autoload_files.php')
609
- if (existsSync(filesFile)) {
610
- found = true
611
- for (const [, abs] of parseGeneratedMap(readFileSync(filesFile, 'utf8'), vars, false)) {
612
- const rel = toRel(abs)
613
- if (rel) files.add(rel)
614
- }
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 fully-qualified class name to a baseDir-relative file via the
622
- // autoload config, mirroring Composer's ClassLoader lookup order: exact
623
- // classmap hit, then longest-matching PSR-4 prefix, then longest-matching
624
- // PSR-0 prefix. Only returns a path that actually exists on disk (so a PSR-4
625
- // prefix match for a class whose file is absent doesn't get bundled as a hole).
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
- // All [prefix, dirs] entries of a PSR map whose prefix is a prefix of `name`,
661
- // longest first -- so resolution prefers the most specific mapping but falls
662
- // back to shorter ones (and the `""` fallback) when a file isn't present.
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
- // --- Per-package bucketing ---------------------------------------------------
668
-
669
- // Read installed-package versions from `vendor/composer/installed.json` -- the
670
- // authoritative source, since a package's own composer.json usually omits
671
- // `version` (Composer derives it at install time). Handles both the Composer 2
672
- // shape ({ packages: [...] }) and the Composer 1 flat array. Returns maps from
673
- // install dir (baseDir-relative) and from package name to version.
674
- function loadInstalledVersions(baseDir, vendorDir) {
675
- const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
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 packages = Array.isArray(json) ? json : (json?.packages ?? [])
679
- for (const p of packages) {
680
- if (!p?.name || !p.version) continue
681
- byName.set(p.name, p.version)
682
- // install-path is relative to vendor/composer; default layout is vendor/<name>.
683
- const abs = p['install-path']
684
- ? resolve(baseDir, vendorDir, 'composer', p['install-path'])
685
- : resolve(baseDir, vendorDir, p.name)
686
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
687
- if (rel && !rel.startsWith('..') && !isAbsolute(rel)) byDir.set(rel, p.version)
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 with a `name`,
693
- // returning { pkgDir, name, version } (pkgDir baseDir-relative, "." for the
694
- // root package). Version comes from installed.json (by dir, then by name),
695
- // falling back to composer.json's own `version`, then `fallbackVersion`.
696
- // Returns null when no named composer.json sits at or above the file.
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 version = installed.byDir.get(dir) ?? installed.byName.get(cj.name) ?? cj.version ?? fallbackVersion
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 directory of
713
- // the nearest composer.json: vendor dependencies land in their own
714
- // `vendor/<vendor>/<pkg>` bucket (name + version from composer.json /
715
- // installed.json), workspace files in the root package's "." bucket. Files with
716
- // no named composer.json above them (e.g. Composer's generated autoloader glue,
717
- // or a project without a composer.json) fall back to "." with the given
718
- // placeholder identity. Returns a Map<dir, { name, version, files }>.
719
- export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
720
- const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
721
- const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
722
- const installed = loadInstalledVersions(baseDir, vendorDir)
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
- // Anything below the root composer package is an installed Composer
739
- // dependency (vendor/<vendor>/<pkg>); tag it `composer`. The root "."
740
- // bucket is the workspace's own code and stays ecosystem-less.
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 that nothing references statically:
751
- // vendor packages declare them under `extra.laravel.providers` (aggregated into
752
- // vendor/composer/installed.json), and the app lists its own in
753
- // bootstrap/providers.php (Laravel 11) or composer.json's `extra.laravel`. Those
754
- // providers do real work in boot() -- publishing config, loading routes/views,
755
- // merging config -- referencing files (`config_path(...)`, `__DIR__ . '/...'`)
756
- // that would otherwise never be reached. Returns the baseDir-relative files of
757
- // every discoverable provider class (resolved via the autoload maps) plus
758
- // bootstrap/providers.php when present, to seed as extra collection roots.
759
- // Best-effort: unresolvable classes (and a missing autoload config) are skipped.
760
- export function loadLaravelProviderFiles(baseDir, autoload) {
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 = readJsonIfExists(join(baseDir, 'composer.json'))
766
- const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
767
- const installed = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
768
- const installedPackages = Array.isArray(installed) ? installed : (installed?.packages ?? [])
769
-
769
+ const { composerJson } = readComposerJson(baseDir)
770
770
  const fqcns = new Set(composerJson?.extra?.laravel?.providers ?? [])
771
- for (const pkg of installedPackages) {
772
- for (const fqcn of pkg?.extra?.laravel?.providers ?? []) fqcns.add(fqcn)
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
- // --- Class-reference extraction ----------------------------------------------
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 out PHP heredocs/nowdocs, comments AND string literals so class-
799
- // reference scans don't trip over namespace-like text, braces, or keywords
800
- // inside them. Unlike `maskCommentsAndHeredocs`, this also removes string
801
- // contents -- class references never live in strings.
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 written as `raw` in a file whose namespace
807
- // is `ns`, given the file's `use`-alias map. Mirrors PHP name resolution: a
808
- // leading `\` is fully-qualified; a first segment matching an alias expands
809
- // through it; otherwise an unqualified/qualified name is relative to the
810
- // current namespace.
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 the body of a `use` statement (everything between `use` and `;`,
831
- // already trimmed) into its entries. Handles comma lists (`A, B as C`) and
832
- // group syntax (`Pre\{A, B as C}`). Returns `[{ name, short }]` where `name` is
833
- // the symbol as written (sans alias) and `short` is the alias or last segment.
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 keyword. PHP
850
- // requires `use` *imports* to appear before any declaration, so this marks the
851
- // end of the import region: a `use` before it is an import (an alias that loads
852
- // nothing), while a `use` after it is a trait use inside a class body (which
853
- // does load the trait). `::class` and member accesses are excluded via the
854
- // lookbehind. Returns Infinity for a file with no class-like declaration.
855
- // Cheaper and more robust than brace-matching -- no nested-brace bookkeeping,
856
- // and heredocs are already stripped out.
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 set of fully-qualified class names a PHP file actually depends
863
- // on. A `use` import only creates an alias and loads nothing, so it is NOT a
864
- // dependency on its own -- only classes referenced at a real site pull a file
865
- // in: new / extends / implements / instanceof / catch / `Foo::bar` / attributes
866
- // / parameter+return type hints / fully-qualified names, plus in-body
867
- // `use Trait;` (which does load the trait).
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
- // Classify `use` statements: those before the first declaration are imports
875
- // (alias map only); those after are trait uses inside a class body, which
876
- // count as references.
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): capture the type list up to the `$var`. A single lazy
908
- // `[^)]*?` (no second whitespace quantifier) keeps this linear even when a
909
- // long blanked run sits inside the parens with no `$var`.
910
- for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
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 / property type hints: `Type $var`, incl. nullable `?Type`,
915
- // variadic `Type ...$var`, by-ref `Type &$var`, and union/intersection
916
- // `A|B`/`A&B $var` (each member counts). The separator before `$var` is one
917
- // `\s+` plus an optional `&`/`...` run -- written so only a single whitespace
918
- // quantifier is unbounded (the inner `\s*` is gated behind a required `[&.]`),
919
- // which avoids catastrophic backtracking when a token is followed by a long
920
- // blanked run (comment/heredoc/string) with no `$var` after it.
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
- // --- Tree / disk collection --------------------------------------------------
933
-
934
- // Resolve every autoloaded class a file references to a baseDir-relative file
935
- // path that exists on disk and is already part of `sources`. Best-effort:
936
- // unresolvable references (builtins, extensions, dynamic) are skipped.
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 the { sources, resolutions, missing } triple from a Map of already-
948
- // loaded PHP sources. Explicit includes are strict (an unresolvable one is
949
- // recorded in `missing`); Composer-autoloaded class references, when an
950
- // `autoload` config is supplied, are recorded best-effort and never added to
951
- // `missing`. As a final include fallback, a specifier that matches a stored
952
- // key verbatim is accepted.
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 starting from `entries` (plus any Composer `files`
978
- // autoload entries), following resolved includes and autoloaded class
979
- // references, reading each file exactly once. Files in the same wave are read
980
- // in parallel; the next wave depends on what the previous one referenced.
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
- return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
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: target doesn't exist. EISDIR: a specifier resolved to a
991
- // directory (e.g. an extensionless `require __DIR__ . '/dir'`). Both
992
- // mean "not a loadable file" -- warn and skip rather than crash; the
993
- // unresolved edge is surfaced later by buildPhpTree for real includes.
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
- // Read in bounded batches: a single wave can be huge (e.g. a dynamic
1006
- // include's directory glob over thousands of files), and an unbounded
1007
- // Promise.all would open every file at once and crash with EMFILE.
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
- for (const spec of extractPhpImports(content)) {
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 directory prefix (e.g.
1027
- // `require __DIR__ . "/views/$view.php"`): the exact file is only known at
1028
- // runtime, so bundle every `.php` file in that directory as a candidate.
1029
- for (const dirSpec of extractPhpImportDirs(content)) {
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 not in a require (e.g. route/config files passed
1037
- // to a framework). Dir-anchored refs resolve against the file; Laravel
1038
- // helper refs (`base_path(...)`) against the project root. Best-effort:
1039
- // bundle only if they resolve to a file that exists.
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 are required unconditionally by Composer, so they
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
  }