@exodus/stasis 1.0.0-beta.2 → 1.0.0-beta.5

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