@exodus/stasis 1.0.0-beta.3 → 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.
@@ -0,0 +1,33 @@
1
+ // Path checks shared by the source-language loaders.
2
+
3
+ import { isAbsolute, relative, resolve } from 'node:path'
4
+
5
+ import { relativeEscapes, toPosix } from '@exodus/stasis-core/util'
6
+
7
+ // Reject absolute and `..`-escaping paths in a `.<lang>.txt` listing so a malicious or sloppy
8
+ // listing can't read files outside the listing's own directory.
9
+ export function assertWithinBase(baseDir, candidate, label) {
10
+ if (isAbsolute(candidate)) throw new Error(`${label} must not be absolute: ${candidate}`)
11
+ const rel = toPosix(relative(baseDir, resolve(baseDir, candidate)))
12
+ if (relativeEscapes(rel)) {
13
+ throw new Error(`${label} escapes baseDir: ${candidate}`)
14
+ }
15
+ }
16
+
17
+ // Apply `segments` on `fromFile`'s directory, resolving '.'/'..'. Returns a baseDir-relative POSIX
18
+ // path ('' for baseDir itself), or null when traversal escapes the root.
19
+ export function applyToDir(fromFile, segments) {
20
+ const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
21
+ const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
22
+ const resolved = []
23
+ for (const part of parts) {
24
+ if (part === '.' || part === '') continue
25
+ if (part === '..') {
26
+ if (resolved.length === 0) return null
27
+ resolved.pop()
28
+ } else {
29
+ resolved.push(part)
30
+ }
31
+ }
32
+ return resolved.join('/')
33
+ }
@@ -3,11 +3,18 @@
3
3
  // (unresolved -> `missing`); Composer-autoloaded class refs are best-effort
4
4
  // (unresolved -> silently skipped).
5
5
 
6
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
6
+ import { isUtf8 } from 'node:buffer'
7
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
7
8
  import { readFile } from 'node:fs/promises'
8
9
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
10
+ import { isDeepStrictEqual } from 'node:util'
9
11
 
10
- 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'
11
18
 
12
19
  // Cap concurrent reads per wave to avoid EMFILE on large directory globs.
13
20
  const READ_CONCURRENCY = 64
@@ -120,6 +127,10 @@ function stringLiterals(code) {
120
127
  return out
121
128
  }
122
129
 
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)
133
+
123
134
  // Include keyword. The lookbehind rejects method/scope-resolution calls
124
135
  // (`$x->require`, `Foo::include`); the trailing `\b` stops matching inside
125
136
  // `requireConfig`. String-literal matches are filtered out by the caller.
@@ -165,13 +176,16 @@ function matchDirAnchor(code, pos) {
165
176
  return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
166
177
  }
167
178
 
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
+
168
182
  // Normalise an include path to a specifier. Dir-relative paths get a `./` prefix
169
183
  // with the leading separator stripped; empty means the file's own dir (`.`).
170
184
  function normalizeIncludePath(raw, dirRelative) {
171
185
  if (!dirRelative) return raw
172
186
  const rel = raw.replace(/^\/+/u, '')
173
187
  if (rel === '') return '.'
174
- return rel.startsWith('.') ? rel : `./${rel}`
188
+ return isDotRelative(rel) ? rel : `./${rel}`
175
189
  }
176
190
 
177
191
  // Parse an include/path argument at `start`: an optional directory anchor then a
@@ -179,7 +193,6 @@ function normalizeIncludePath(raw, dirRelative) {
179
193
  // `dynamic` at the first variable/function/constant/interpolation; `prefix` is
180
194
  // the static path up to there. Returns null with neither anchor nor static string.
181
195
  function parseArgPath(code, start, strings) {
182
- const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
183
196
  let i = start
184
197
  let dirRelative = false
185
198
  let up = 0
@@ -196,7 +209,7 @@ function parseArgPath(code, start, strings) {
196
209
  let dynamic = false
197
210
  let sawStatic = false
198
211
  for (;;) {
199
- const lit = stringAt(i)
212
+ const lit = stringAt(strings, i)
200
213
  if (!lit) { dynamic = true; break }
201
214
  const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
202
215
  if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
@@ -216,11 +229,10 @@ function parseArgPath(code, start, strings) {
216
229
  function scanIncludes(content) {
217
230
  const code = maskCommentsAndHeredocs(content)
218
231
  const strings = stringLiterals(code)
219
- const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
220
232
 
221
233
  const out = []
222
234
  for (const m of code.matchAll(PHP_INCLUDE_KEYWORD_RE)) {
223
- if (inString(m.index)) continue
235
+ if (inString(strings, m.index)) continue
224
236
  let i = skipWs(code, m.index + m[0].length)
225
237
  if (code[i] === '(') i = skipWs(code, i + 1)
226
238
  const arg = parseArgPath(code, i, strings)
@@ -247,13 +259,11 @@ function scanIncludes(content) {
247
259
  function scanPathLiterals(content) {
248
260
  const code = maskCommentsAndHeredocs(content)
249
261
  const strings = stringLiterals(code)
250
- const inString = (pos) => strings.some(({ start, end }) => pos > start && pos < end)
251
- const stringAt = (pos) => strings.find(({ start }) => start === pos)
252
262
 
253
263
  const out = []
254
264
  // Dir-anchored literals: `__DIR__ . '/x.php'` (resolve against the file).
255
265
  for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
256
- if (inString(m.index)) continue
266
+ if (inString(strings, m.index)) continue
257
267
  const arg = parseArgPath(code, m.index, strings)
258
268
  if (!arg || arg.dynamic || !arg.sawStatic || !arg.dirRelative) continue
259
269
  const spec = normalizeIncludePath(arg.prefix, true)
@@ -261,8 +271,8 @@ function scanPathLiterals(content) {
261
271
  }
262
272
  // Laravel path helpers: `base_path('routes/web.php')` (resolve against project root).
263
273
  for (const m of code.matchAll(PHP_PATH_HELPER_FIND_RE)) {
264
- if (inString(m.index)) continue
265
- 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))
266
276
  if (!lit || (lit.quote === '"' && PHP_INTERPOLATION_RE.test(lit.value))) continue
267
277
  const sub = PHP_PATH_HELPERS.get(m[1].toLowerCase())
268
278
  const arg = lit.value.replace(/^\/+/u, '')
@@ -272,15 +282,17 @@ function scanPathLiterals(content) {
272
282
  return out
273
283
  }
274
284
 
285
+ const includeSpecs = (includes, kind) => includes.filter((e) => e.kind === kind).map((e) => e.spec)
286
+
275
287
  // Statically-resolvable include specifiers (concrete files). See scanIncludes.
276
288
  export function extractPhpImports(content) {
277
- return scanIncludes(content).filter((e) => e.kind === 'file').map((e) => e.spec)
289
+ return includeSpecs(scanIncludes(content), 'file')
278
290
  }
279
291
 
280
292
  // Directory specifiers for dynamic includes; every `.php` file directly inside is
281
293
  // a candidate target.
282
294
  export function extractPhpImportDirs(content) {
283
- return scanIncludes(content).filter((e) => e.kind === 'dir').map((e) => e.spec)
295
+ return includeSpecs(scanIncludes(content), 'dir')
284
296
  }
285
297
 
286
298
  // `.php` path literals used outside a `require` -- dir-anchored (`anchor: 'file'`)
@@ -295,55 +307,35 @@ export function extractPhpPathRefs(content) {
295
307
  })
296
308
  }
297
309
 
298
- // Apply `segments` on `fromFile`'s directory, resolving '.'/'..'. Returns a
299
- // baseDir-relative POSIX path, or null when traversal escapes the root.
300
- function applyToDir(fromFile, segments) {
301
- const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
302
- const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
303
- const resolved = []
304
- for (const part of parts) {
305
- if (part === '.' || part === '') continue
306
- if (part === '..') {
307
- if (resolved.length === 0) return null
308
- resolved.pop()
309
- } else {
310
- resolved.push(part)
311
- }
312
- }
313
- return resolved.join('/')
314
- }
310
+ const isFileOnDisk = (baseDir, rel) => isFile(join(baseDir, rel))
311
+ const isDirOnDisk = (baseDir, rel) => isDir(join(baseDir, rel))
315
312
 
316
- function isFileOnDisk(baseDir, rel) {
317
- try {
318
- return statSync(join(baseDir, rel)).isFile()
319
- } catch {
320
- return false
321
- }
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})` : ''}`)
322
317
  }
323
318
 
324
- function isDirOnDisk(baseDir, rel) {
325
- try {
326
- return statSync(join(baseDir, rel)).isDirectory()
327
- } catch {
328
- return false
329
- }
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
330
323
  }
331
324
 
332
325
  // Resolve a directory specifier to an existing baseDir-relative dir, or null.
333
326
  // `./`/`../` resolve against the including file; a bare spec tries file- then
334
327
  // project-relative.
335
328
  export function resolvePhpDir(spec, fromFile, baseDir) {
336
- if (spec.startsWith('.')) {
329
+ refuseBackslash(spec, fromFile)
330
+ if (isDotRelative(spec)) {
337
331
  const rel = applyToDir(fromFile, spec.split('/'))
338
332
  return rel !== null && isDirOnDisk(baseDir, rel) ? rel : null
339
333
  }
340
334
  if (baseDir && !isAbsolute(spec)) {
341
335
  const fileRel = applyToDir(fromFile, spec.split('/'))
342
336
  if (fileRel && isDirOnDisk(baseDir, fileRel)) return fileRel
343
- const projRel = relative(baseDir, resolve(baseDir, spec)).split(/[\\/]/u).join('/')
344
- if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isDirOnDisk(baseDir, projRel)) {
345
- return projRel
346
- }
337
+ const projRel = projectRel(baseDir, resolve(baseDir, spec))
338
+ if (projRel && isDirOnDisk(baseDir, projRel)) return projRel
347
339
  }
348
340
  return null
349
341
  }
@@ -363,9 +355,11 @@ function listPhpFiles(baseDir, dir) {
363
355
  // Resolve a PHP include specifier to a baseDir-relative POSIX path. `./`/`../`
364
356
  // resolve against the including file (escaping the root -> null); a bare specifier
365
357
  // tries a file next to the includer, then project-root-relative. Absolute
366
- // specifiers are rejected. Returns null when nothing resolves.
358
+ // specifiers are rejected. Returns null when nothing resolves. One holding `\` throws
359
+ // (see refuseBackslash).
367
360
  export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
368
- if (specifier.startsWith('.')) {
361
+ refuseBackslash(specifier, fromFile)
362
+ if (isDotRelative(specifier)) {
369
363
  return applyToDir(fromFile, specifier.split('/'))
370
364
  }
371
365
 
@@ -373,10 +367,8 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
373
367
  const fileRel = applyToDir(fromFile, specifier.split('/'))
374
368
  if (fileRel && isFileOnDisk(baseDir, fileRel)) return fileRel
375
369
 
376
- const projRel = relative(baseDir, resolve(baseDir, specifier)).split(/[\\/]/u).join('/')
377
- if (projRel !== '' && !projRel.startsWith('..') && !isAbsolute(projRel) && isFileOnDisk(baseDir, projRel)) {
378
- return projRel
379
- }
370
+ const projRel = projectRel(baseDir, resolve(baseDir, specifier))
371
+ if (projRel && isFileOnDisk(baseDir, projRel)) return projRel
380
372
  }
381
373
 
382
374
  return null
@@ -385,12 +377,11 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
385
377
  // Normalise a project-root-relative autoload path to POSIX baseDir-relative, or
386
378
  // null when it escapes root. Re-derives via resolve() so interior `..` escapes
387
379
  // (`src/foo/../../../secret.php`) are caught -- a leading-`..`/absolute check
388
- // alone would let files outside the project into the bundle.
380
+ // alone would let files outside the project into the bundle. One holding `\`
381
+ // throws (see refuseBackslash).
389
382
  function normalizeProjectRel(baseDir, p) {
390
- const rel = relative(baseDir, resolve(baseDir, p.replace(/\\/gu, '/'))).split(/[\\/]/u).join('/')
391
- if (rel === '') return ''
392
- if (rel.startsWith('..') || isAbsolute(rel)) return null
393
- return rel
383
+ refuseBackslash(p)
384
+ return projectRel(baseDir, resolve(baseDir, p))
394
385
  }
395
386
 
396
387
  // Evaluate a generated-autoload path expr (`$baseDir . '/src'`, a bare quoted
@@ -453,13 +444,127 @@ function readJsonIfExists(file) {
453
444
  }
454
445
  }
455
446
 
447
+ // The root composer.json (null when absent or unparsable) and its baseDir-relative vendor dir.
448
+ function readComposerJson(baseDir) {
449
+ const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
450
+ const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
451
+ return { composerJson, vendorDir }
452
+ }
453
+
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
+
456
562
  // Build the autoload config for `baseDir`, merging the root composer.json
457
563
  // `autoload`(+dev) with the generated `vendor/composer/autoload_*` maps. All
458
564
  // paths normalised to POSIX baseDir-relative (escapers dropped). Returns
459
565
  // `{ psr4, psr0, classmap, files }`, or null when no Composer config is found.
460
566
  export function loadComposerAutoload(baseDir) {
461
- const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
462
- const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
567
+ const { composerJson, vendorDir } = readComposerJson(baseDir)
463
568
 
464
569
  const psr4 = new Map()
465
570
  const psr0 = new Map()
@@ -467,6 +572,7 @@ export function loadComposerAutoload(baseDir) {
467
572
  const files = new Set()
468
573
  let found = false
469
574
 
575
+ const mergeDirs = (map, prefix, dirs) => map.set(prefix, [...new Set([...(map.get(prefix) ?? []), ...dirs])])
470
576
  const addPrefixDirs = (map, prefix, paths) => {
471
577
  const list = Array.isArray(paths) ? paths : [paths]
472
578
  const dirs = []
@@ -474,8 +580,7 @@ export function loadComposerAutoload(baseDir) {
474
580
  const rel = normalizeProjectRel(baseDir, String(p))
475
581
  if (rel !== null) dirs.push(rel)
476
582
  }
477
- const prev = map.get(prefix) ?? []
478
- map.set(prefix, [...new Set([...prev, ...dirs])])
583
+ mergeDirs(map, prefix, dirs)
479
584
  }
480
585
 
481
586
  // Root composer.json autoload (+ dev). PSR-4 keys keep their trailing '\'.
@@ -493,37 +598,26 @@ export function loadComposerAutoload(baseDir) {
493
598
  // Generated maps. `$vendorDir` = <baseDir>/<vendorDir>, `$baseDir` = <baseDir>.
494
599
  const composerGenDir = join(baseDir, vendorDir, 'composer')
495
600
  const vars = { baseDir: resolve(baseDir), vendorDir: resolve(baseDir, vendorDir) }
496
- const toRel = (abs) => {
497
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
498
- return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) ? rel : null
499
- }
500
- const mergePrefixMap = (file, target) => {
501
- 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()
502
606
  found = true
503
- for (const [prefix, dirs] of parseGeneratedMap(readFileSync(file, 'utf8'), vars, true)) {
504
- const rels = dirs.map(toRel).filter((d) => d !== null).map((d) => d.replace(/\/+$/u, ''))
505
- const prev = target.get(prefix) ?? []
506
- target.set(prefix, [...new Set([...prev, ...rels])])
507
- }
607
+ return parseGeneratedMap(readFileSync(file, 'utf8'), vars, multi)
508
608
  }
509
- mergePrefixMap(join(composerGenDir, 'autoload_psr4.php'), psr4)
510
- mergePrefixMap(join(composerGenDir, 'autoload_namespaces.php'), psr0)
511
-
512
- const classmapFile = join(composerGenDir, 'autoload_classmap.php')
513
- if (existsSync(classmapFile)) {
514
- found = true
515
- for (const [fqcn, abs] of parseGeneratedMap(readFileSync(classmapFile, 'utf8'), vars, false)) {
516
- const rel = toRel(abs)
517
- 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, '')))
518
612
  }
519
613
  }
520
- const filesFile = join(composerGenDir, 'autoload_files.php')
521
- if (existsSync(filesFile)) {
522
- found = true
523
- for (const [, abs] of parseGeneratedMap(readFileSync(filesFile, 'utf8'), vars, false)) {
524
- const rel = toRel(abs)
525
- if (rel) files.add(rel)
526
- }
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)
527
621
  }
528
622
 
529
623
  if (!found) return null
@@ -573,30 +667,21 @@ function matchingPrefixes(map, name) {
573
667
  return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
574
668
  }
575
669
 
576
- // Read installed-package versions from `vendor/composer/installed.json` (the
577
- // authoritative source; a package's own composer.json usually omits `version`).
578
- // Handles the Composer 2 ({ packages }) and Composer 1 (flat array) shapes.
579
- function loadInstalledVersions(baseDir, vendorDir) {
580
- 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) {
581
673
  const byDir = new Map()
582
674
  const byName = new Map()
583
- const packages = Array.isArray(json) ? json : (json?.packages ?? [])
584
- for (const p of packages) {
585
- if (!p?.name || !p.version) continue
586
- byName.set(p.name, p.version)
587
- // install-path is relative to vendor/composer; default layout is vendor/<name>.
588
- const abs = p['install-path']
589
- ? resolve(baseDir, vendorDir, 'composer', p['install-path'])
590
- : resolve(baseDir, vendorDir, p.name)
591
- const rel = relative(baseDir, abs).split(/[\\/]/u).join('/')
592
- 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)
593
678
  }
594
679
  return { byDir, byName }
595
680
  }
596
681
 
597
682
  // Walk up from a bundled file to the nearest named composer.json, returning
598
- // { pkgDir, name, version } (pkgDir "." for the root; version from installed.json
599
- // by dir/name, else composer.json `version`, else `fallbackVersion`). Null if none.
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.
600
685
  function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
601
686
  let dir = dirname(fileRelPath)
602
687
  for (;;) {
@@ -615,11 +700,9 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
615
700
  // Group bundled PHP sources into per-package buckets keyed by the nearest
616
701
  // composer.json's directory (vendor deps get their own; workspace files and
617
702
  // orphans -> the root "." bucket with the placeholder identity). Returns a
618
- // Map<dir, { name, version, files }>.
619
- export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
620
- const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
621
- const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
622
- const installed = loadInstalledVersions(baseDir, vendorDir)
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)
623
706
 
624
707
  const modules = new Map()
625
708
  const ensureBucket = (dir, name, version, ecosystem) => {
@@ -647,22 +730,19 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
647
730
  }
648
731
 
649
732
  // Laravel auto-registers service providers nothing references statically. Returns
650
- // discoverable provider files (from composer.json + installed.json
733
+ // discoverable provider files (from composer.json + the Composer `packages`'
651
734
  // `extra.laravel.providers`, autoload-resolved) plus bootstrap/providers.php as
652
735
  // extra collection roots. Best-effort: unresolvable classes skipped.
653
- export function loadLaravelProviderFiles(baseDir, autoload) {
736
+ // `packages`: loadComposerPackages(baseDir).
737
+ export function loadLaravelProviderFiles(baseDir, autoload, packages = loadComposerPackages(baseDir)) {
654
738
  const files = new Set()
655
739
  if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
656
740
  if (!autoload) return [...files]
657
741
 
658
- const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
659
- const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
660
- const installed = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
661
- const installedPackages = Array.isArray(installed) ? installed : (installed?.packages ?? [])
662
-
742
+ const { composerJson } = readComposerJson(baseDir)
663
743
  const fqcns = new Set(composerJson?.extra?.laravel?.providers ?? [])
664
- for (const pkg of installedPackages) {
665
- 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)
666
746
  }
667
747
  for (const fqcn of fqcns) {
668
748
  const file = resolveClassFile(fqcn, autoload, baseDir)
@@ -852,7 +932,10 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
852
932
  const readOne = async (relPath) => {
853
933
  try {
854
934
  assertRealPathWithinBase(realBase, baseDir, relPath)
855
- 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')]
856
939
  } catch (err) {
857
940
  // ENOENT (no file) / EISDIR (specifier resolved to a directory): not a
858
941
  // loadable file -- warn and skip rather than crash (buildPhpTree surfaces
@@ -880,7 +963,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
880
963
  if (!entry) continue
881
964
  const [relPath, content] = entry
882
965
  sources.set(relPath, content)
883
- for (const spec of extractPhpImports(content)) {
966
+ const includes = scanIncludes(content)
967
+ for (const spec of includeSpecs(includes, 'file')) {
884
968
  const resolved = resolvePhpImport(spec, relPath, { baseDir })
885
969
  if (resolved) {
886
970
  if (!sources.has(resolved)) next.push(resolved)
@@ -890,7 +974,7 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
890
974
  }
891
975
  // Dynamic includes with a static dir prefix: the exact file is known only
892
976
  // at runtime, so bundle every `.php` file in that directory as a candidate.
893
- for (const dirSpec of extractPhpImportDirs(content)) {
977
+ for (const dirSpec of includeSpecs(includes, 'dir')) {
894
978
  const dir = resolvePhpDir(dirSpec, relPath, baseDir)
895
979
  if (!dir) continue
896
980
  for (const file of listPhpFiles(baseDir, dir)) {