@exodus/stasis 1.0.0-beta.1 → 1.0.0-beta.3

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.
@@ -1,36 +1,7 @@
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.
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).
34
5
 
35
6
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'
36
7
  import { readFile } from 'node:fs/promises'
@@ -38,15 +9,11 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
38
9
 
39
10
  import { assertRealPathWithinBase } from '@exodus/stasis-core/util'
40
11
 
41
- // Max files read concurrently per wave, to avoid EMFILE on large directory globs.
12
+ // Cap concurrent reads per wave to avoid EMFILE on large directory globs.
42
13
  const READ_CONCURRENCY = 64
43
14
 
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.
15
+ // End index (exclusive) of the heredoc/nowdoc opener at `start`, or `start` if
16
+ // not a valid opener. Consumes to EOF when unterminated.
50
17
  function heredocEnd(code, start) {
51
18
  const n = code.length
52
19
  let j = start + 3
@@ -76,15 +43,9 @@ function heredocEnd(code, start) {
76
43
  return n
77
44
  }
78
45
 
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.
46
+ // Blank everything that isn't executable PHP (comments, heredocs/nowdocs, and
47
+ // unless `keepStrings` string literals) to spaces, preserving newlines so byte
48
+ // offsets stay aligned with the input. `#[` (a PHP 8 attribute) is left intact.
88
49
  function maskNonCode(code, keepStrings) {
89
50
  const n = code.length
90
51
  const blank = (s) => s.replace(/[^\n]/gu, ' ')
@@ -122,18 +83,14 @@ function maskNonCode(code, keepStrings) {
122
83
  return out
123
84
  }
124
85
 
125
- // Comments/heredocs blanked, string literals kept (include paths live in
126
- // strings). See maskNonCode.
86
+ // Blank comments/heredocs, keep string literals (include paths live in strings).
127
87
  function maskCommentsAndHeredocs(content) {
128
88
  return maskNonCode(content, true)
129
89
  }
130
90
 
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.
91
+ // Scan string literals -> { start, end, value, quote } with PHP single-quote
92
+ // unescaping (only `\\` and the closing quote are escapes; other backslashes
93
+ // literal -- good enough for path values).
137
94
  function stringLiterals(code) {
138
95
  const out = []
139
96
  let i = 0
@@ -163,25 +120,19 @@ function stringLiterals(code) {
163
120
  return out
164
121
  }
165
122
 
166
- // --- Explicit includes -------------------------------------------------------
167
-
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.
123
+ // Include keyword. The lookbehind rejects method/scope-resolution calls
124
+ // (`$x->require`, `Foo::include`); the trailing `\b` stops matching inside
125
+ // `requireConfig`. String-literal matches are filtered out by the caller.
173
126
  const PHP_INCLUDE_KEYWORD_RE = /(?<![\w$>:])(?:require_once|require|include_once|include)\b/gidu
174
127
 
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).
128
+ // Directory anchor (sticky): `__DIR__`/`dirname(__FILE__)` (file's own dir) or
129
+ // `dirname(__DIR__[, N])` (N parent levels up).
178
130
  const PHP_DIR_ANCHOR_RE = /__DIR__\b|dirname\s*\(\s*(__FILE__|__DIR__)\s*(?:,\s*(\d+)\s*)?\)/y
179
131
  // Anchor starts, for scanning path literals anywhere (not just after `require`).
180
132
  const PHP_DIR_ANCHOR_FIND_RE = /(?<![\w$>])(?:__DIR__\b|dirname\s*\(\s*(?:__FILE__|__DIR__))/gu
181
133
 
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.
134
+ // Laravel path helpers resolving a string arg relative to the project root (or a
135
+ // fixed subdirectory); they name real PHP files the framework require()s.
185
136
  const PHP_PATH_HELPERS = new Map([
186
137
  ['base_path', ''],
187
138
  ['app_path', 'app'],
@@ -205,8 +156,7 @@ function skipWs(code, pos) {
205
156
  }
206
157
 
207
158
  // 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.
159
+ // above the file's dir; 0 for `__DIR__`/`dirname(__FILE__)`); else null.
210
160
  function matchDirAnchor(code, pos) {
211
161
  PHP_DIR_ANCHOR_RE.lastIndex = pos
212
162
  const m = PHP_DIR_ANCHOR_RE.exec(code)
@@ -215,9 +165,8 @@ function matchDirAnchor(code, pos) {
215
165
  return { end: PHP_DIR_ANCHOR_RE.lastIndex, up }
216
166
  }
217
167
 
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" (`.`).
168
+ // Normalise an include path to a specifier. Dir-relative paths get a `./` prefix
169
+ // with the leading separator stripped; empty means the file's own dir (`.`).
221
170
  function normalizeIncludePath(raw, dirRelative) {
222
171
  if (!dirRelative) return raw
223
172
  const rel = raw.replace(/^\/+/u, '')
@@ -225,14 +174,10 @@ function normalizeIncludePath(raw, dirRelative) {
225
174
  return rel.startsWith('.') ? rel : `./${rel}`
226
175
  }
227
176
 
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.
177
+ // Parse an include/path argument at `start`: an optional directory anchor then a
178
+ // concatenation chain of string literals (static literals fold together). Goes
179
+ // `dynamic` at the first variable/function/constant/interpolation; `prefix` is
180
+ // the static path up to there. Returns null with neither anchor nor static string.
236
181
  function parseArgPath(code, start, strings) {
237
182
  const stringAt = (pos) => strings.find(({ start: s }) => s === pos)
238
183
  let i = start
@@ -243,7 +188,7 @@ function parseArgPath(code, start, strings) {
243
188
  i = skipWs(code, anchor.end)
244
189
  dirRelative = true
245
190
  up = anchor.up
246
- if (code[i] !== '.') return null // anchor not concatenated with a path
191
+ if (code[i] !== '.') return null
247
192
  i = skipWs(code, i + 1)
248
193
  }
249
194
 
@@ -252,25 +197,22 @@ function parseArgPath(code, start, strings) {
252
197
  let sawStatic = false
253
198
  for (;;) {
254
199
  const lit = stringAt(i)
255
- if (!lit) { dynamic = true; break } // variable/constant/function -> dynamic
200
+ if (!lit) { dynamic = true; break }
256
201
  const interp = lit.quote === '"' ? lit.value.search(PHP_INTERPOLATION_RE) : -1
257
202
  if (interp >= 0) { prefix += lit.value.slice(0, interp); sawStatic = true; dynamic = true; break }
258
203
  prefix += lit.value
259
204
  sawStatic = true
260
205
  i = skipWs(code, lit.end)
261
- if (code[i] === '.') { i = skipWs(code, i + 1); continue } // concatenated -> keep going
206
+ if (code[i] === '.') { i = skipWs(code, i + 1); continue }
262
207
  break
263
208
  }
264
209
  if (!dirRelative && !sawStatic) return null
265
210
  return { dirRelative, up, prefix, dynamic, sawStatic }
266
211
  }
267
212
 
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.
213
+ // Scan `require`/`include` statements, classifying each as a concrete `file` or
214
+ // -- when the path is dynamic but has a static directory prefix -- a `dir` whose
215
+ // `.php` files are all bundled (any could be the runtime target).
274
216
  function scanIncludes(content) {
275
217
  const code = maskCommentsAndHeredocs(content)
276
218
  const strings = stringLiterals(code)
@@ -289,23 +231,19 @@ function scanIncludes(content) {
289
231
  if (sawStatic) out.push({ kind: 'file', spec: normalizeIncludePath(prefix, dirRelative) })
290
232
  continue
291
233
  }
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.
234
+ // Dynamic path: fall back to the static dir prefix; a bare path with no separator has none.
294
235
  if (!dirRelative && !prefix.includes('/')) continue
295
236
  const lastSlash = prefix.lastIndexOf('/')
296
237
  let dir = lastSlash >= 0 ? prefix.slice(0, lastSlash) : ''
297
- if (dir === '' && up > 0) dir = '/..'.repeat(up) // `dirname(__DIR__) . $x` -> parent dir
238
+ if (dir === '' && up > 0) dir = '/..'.repeat(up)
298
239
  out.push({ kind: 'dir', spec: normalizeIncludePath(dir, dirRelative) })
299
240
  }
300
241
  return out
301
242
  }
302
243
 
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.
244
+ // Dir-anchored static `.php` path literals anywhere in the file (not just after
245
+ // `require`) -- frameworks pass these to code that require()s them internally.
246
+ // Best-effort: bundled only if they resolve to an existing file.
309
247
  function scanPathLiterals(content) {
310
248
  const code = maskCommentsAndHeredocs(content)
311
249
  const strings = stringLiterals(code)
@@ -313,7 +251,7 @@ function scanPathLiterals(content) {
313
251
  const stringAt = (pos) => strings.find(({ start }) => start === pos)
314
252
 
315
253
  const out = []
316
- // Dir-anchored literals: `__DIR__ . '/x.php'` (anchor: resolve against the file).
254
+ // Dir-anchored literals: `__DIR__ . '/x.php'` (resolve against the file).
317
255
  for (const m of code.matchAll(PHP_DIR_ANCHOR_FIND_RE)) {
318
256
  if (inString(m.index)) continue
319
257
  const arg = parseArgPath(code, m.index, strings)
@@ -321,8 +259,7 @@ function scanPathLiterals(content) {
321
259
  const spec = normalizeIncludePath(arg.prefix, true)
322
260
  if (spec.endsWith('.php')) out.push({ spec, anchor: 'file' })
323
261
  }
324
- // Laravel path helpers: `base_path('routes/web.php')` etc. (anchor: resolve
325
- // against the project root, with the helper's fixed subdirectory prefixed).
262
+ // Laravel path helpers: `base_path('routes/web.php')` (resolve against project root).
326
263
  for (const m of code.matchAll(PHP_PATH_HELPER_FIND_RE)) {
327
264
  if (inString(m.index)) continue
328
265
  const lit = stringAt(skipWs(code, m.index + m[0].length))
@@ -340,16 +277,14 @@ export function extractPhpImports(content) {
340
277
  return scanIncludes(content).filter((e) => e.kind === 'file').map((e) => e.spec)
341
278
  }
342
279
 
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.
280
+ // Directory specifiers for dynamic includes; every `.php` file directly inside is
281
+ // a candidate target.
345
282
  export function extractPhpImportDirs(content) {
346
283
  return scanIncludes(content).filter((e) => e.kind === 'dir').map((e) => e.spec)
347
284
  }
348
285
 
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).
286
+ // `.php` path literals used outside a `require` -- dir-anchored (`anchor: 'file'`)
287
+ // or via a Laravel path helper (`anchor: 'root'`). De-duplicated; best-effort.
353
288
  export function extractPhpPathRefs(content) {
354
289
  const seen = new Set()
355
290
  return scanPathLiterals(content).filter(({ spec, anchor }) => {
@@ -360,9 +295,8 @@ export function extractPhpPathRefs(content) {
360
295
  })
361
296
  }
362
297
 
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).
298
+ // Apply `segments` on `fromFile`'s directory, resolving '.'/'..'. Returns a
299
+ // baseDir-relative POSIX path, or null when traversal escapes the root.
366
300
  function applyToDir(fromFile, segments) {
367
301
  const fromDir = fromFile.includes('/') ? fromFile.slice(0, fromFile.lastIndexOf('/')) : ''
368
302
  const parts = [...(fromDir ? fromDir.split('/') : []), ...segments]
@@ -395,9 +329,9 @@ function isDirOnDisk(baseDir, rel) {
395
329
  }
396
330
  }
397
331
 
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.
332
+ // Resolve a directory specifier to an existing baseDir-relative dir, or null.
333
+ // `./`/`../` resolve against the including file; a bare spec tries file- then
334
+ // project-relative.
401
335
  export function resolvePhpDir(spec, fromFile, baseDir) {
402
336
  if (spec.startsWith('.')) {
403
337
  const rel = applyToDir(fromFile, spec.split('/'))
@@ -414,9 +348,8 @@ export function resolvePhpDir(spec, fromFile, baseDir) {
414
348
  return null
415
349
  }
416
350
 
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.
351
+ // The `.php` files directly in `dir` (baseDir-relative). Non-recursive: recursing
352
+ // could pull in an unbounded subtree.
420
353
  function listPhpFiles(baseDir, dir) {
421
354
  try {
422
355
  return readdirSync(join(baseDir, dir), { withFileTypes: true })
@@ -427,14 +360,10 @@ function listPhpFiles(baseDir, dir) {
427
360
  }
428
361
  }
429
362
 
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.
363
+ // Resolve a PHP include specifier to a baseDir-relative POSIX path. `./`/`../`
364
+ // resolve against the including file (escaping the root -> null); a bare specifier
365
+ // tries a file next to the includer, then project-root-relative. Absolute
366
+ // specifiers are rejected. Returns null when nothing resolves.
438
367
  export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
439
368
  if (specifier.startsWith('.')) {
440
369
  return applyToDir(fromFile, specifier.split('/'))
@@ -453,14 +382,10 @@ export function resolvePhpImport(specifier, fromFile, { baseDir } = {}) {
453
382
  return null
454
383
  }
455
384
 
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.
385
+ // Normalise a project-root-relative autoload path to POSIX baseDir-relative, or
386
+ // null when it escapes root. Re-derives via resolve() so interior `..` escapes
387
+ // (`src/foo/../../../secret.php`) are caught -- a leading-`..`/absolute check
388
+ // alone would let files outside the project into the bundle.
464
389
  function normalizeProjectRel(baseDir, p) {
465
390
  const rel = relative(baseDir, resolve(baseDir, p.replace(/\\/gu, '/'))).split(/[\\/]/u).join('/')
466
391
  if (rel === '') return ''
@@ -468,11 +393,9 @@ function normalizeProjectRel(baseDir, p) {
468
393
  return rel
469
394
  }
470
395
 
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.
396
+ // Evaluate a generated-autoload path expr (`$baseDir . '/src'`, a bare quoted
397
+ // string) given the `$baseDir`/`$vendorDir` values. Returns null on anything
398
+ // unrecognised (function call, unknown variable) rather than guessing.
476
399
  function evalPathExpr(expr, vars) {
477
400
  const s = expr.trim().replace(/,+$/u, '').trim()
478
401
  let out = ''
@@ -480,9 +403,8 @@ function evalPathExpr(expr, vars) {
480
403
  const re = /\$(\w+)|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"|\.|\s+/gy
481
404
  let m
482
405
  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.
406
+ // A sticky regex resets lastIndex to 0 when exec() returns null, so track how
407
+ // far we parsed instead of reading lastIndex after the loop.
486
408
  consumed = re.lastIndex
487
409
  if (m[1] !== undefined) {
488
410
  if (vars[m[1]] === undefined) return null
@@ -492,18 +414,14 @@ function evalPathExpr(expr, vars) {
492
414
  } else if (m[3] !== undefined) {
493
415
  out += m[3]
494
416
  }
495
- // '.' (concatenation) and whitespace contribute nothing
496
417
  }
497
418
  if (consumed !== s.length) return null
498
419
  return out
499
420
  }
500
421
 
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.
422
+ // Parse a Composer generated `autoload_*.php` map (one entry per line). `multi`
423
+ // true -> psr-4/psr-0 (key -> dirs[]); false -> classmap/files (key -> file).
424
+ // PHP single-quote escapes in keys are unfolded.
507
425
  function parseGeneratedMap(content, vars, multi) {
508
426
  const out = new Map()
509
427
  for (const line of content.split('\n')) {
@@ -535,24 +453,18 @@ function readJsonIfExists(file) {
535
453
  }
536
454
  }
537
455
 
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.
456
+ // Build the autoload config for `baseDir`, merging the root composer.json
457
+ // `autoload`(+dev) with the generated `vendor/composer/autoload_*` maps. All
458
+ // paths normalised to POSIX baseDir-relative (escapers dropped). Returns
459
+ // `{ psr4, psr0, classmap, files }`, or null when no Composer config is found.
548
460
  export function loadComposerAutoload(baseDir) {
549
461
  const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
550
462
  const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
551
463
 
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
464
+ const psr4 = new Map()
465
+ const psr0 = new Map()
466
+ const classmap = new Map()
467
+ const files = new Set()
556
468
  let found = false
557
469
 
558
470
  const addPrefixDirs = (map, prefix, paths) => {
@@ -618,11 +530,9 @@ export function loadComposerAutoload(baseDir) {
618
530
  return { psr4, psr0, classmap, files: [...files] }
619
531
  }
620
532
 
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).
533
+ // Resolve a FQCN to a baseDir-relative file via the autoload config, mirroring
534
+ // Composer's lookup order: classmap, then longest PSR-4 prefix, then PSR-0. Only
535
+ // returns a path that exists on disk (an absent prefix match isn't bundled as a hole).
626
536
  export function resolveClassFile(fqcn, autoload, baseDir) {
627
537
  if (!autoload || !fqcn) return null
628
538
  const name = fqcn.replace(/^\\+/u, '')
@@ -657,20 +567,15 @@ export function resolveClassFile(fqcn, autoload, baseDir) {
657
567
  return null
658
568
  }
659
569
 
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.
570
+ // [prefix, dirs] entries whose prefix is a prefix of `name`, longest first --
571
+ // prefer the most specific mapping, fall back to shorter (and the `""` fallback).
663
572
  function matchingPrefixes(map, name) {
664
573
  return [...map].filter(([prefix]) => name.startsWith(prefix)).toSorted((a, b) => b[0].length - a[0].length)
665
574
  }
666
575
 
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.
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.
674
579
  function loadInstalledVersions(baseDir, vendorDir) {
675
580
  const json = readJsonIfExists(join(baseDir, vendorDir, 'composer', 'installed.json'))
676
581
  const byDir = new Map()
@@ -689,11 +594,9 @@ function loadInstalledVersions(baseDir, vendorDir) {
689
594
  return { byDir, byName }
690
595
  }
691
596
 
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.
597
+ // 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.
697
600
  function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
698
601
  let dir = dirname(fileRelPath)
699
602
  for (;;) {
@@ -709,13 +612,10 @@ function findComposerPackage(baseDir, fileRelPath, installed, fallbackVersion) {
709
612
  }
710
613
  }
711
614
 
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 }>.
615
+ // Group bundled PHP sources into per-package buckets keyed by the nearest
616
+ // composer.json's directory (vendor deps get their own; workspace files and
617
+ // orphans -> the root "." bucket with the placeholder identity). Returns a
618
+ // Map<dir, { name, version, files }>.
719
619
  export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVersion) {
720
620
  const composerJson = readJsonIfExists(join(baseDir, 'composer.json'))
721
621
  const vendorDir = normalizeProjectRel(baseDir, composerJson?.config?.['vendor-dir'] ?? 'vendor') ?? 'vendor'
@@ -735,9 +635,8 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
735
635
  const pkg = findComposerPackage(baseDir, path, installed, fallbackVersion)
736
636
  if (pkg) {
737
637
  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.
638
+ // Below the root package = an installed Composer dependency, tagged
639
+ // `composer`; the root "." bucket is workspace code and stays ecosystem-less.
741
640
  const ecosystem = pkg.pkgDir === '.' ? undefined : 'composer'
742
641
  ensureBucket(pkg.pkgDir, pkg.name, pkg.version, ecosystem).files[rel] = content
743
642
  } else {
@@ -747,16 +646,10 @@ export function bucketizePhpSources(baseDir, sources, fallbackName, fallbackVers
747
646
  return modules
748
647
  }
749
648
 
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.
649
+ // Laravel auto-registers service providers nothing references statically. Returns
650
+ // discoverable provider files (from composer.json + installed.json
651
+ // `extra.laravel.providers`, autoload-resolved) plus bootstrap/providers.php as
652
+ // extra collection roots. Best-effort: unresolvable classes skipped.
760
653
  export function loadLaravelProviderFiles(baseDir, autoload) {
761
654
  const files = new Set()
762
655
  if (existsSync(join(baseDir, 'bootstrap', 'providers.php'))) files.add('bootstrap/providers.php')
@@ -778,36 +671,28 @@ export function loadLaravelProviderFiles(baseDir, autoload) {
778
671
  return [...files]
779
672
  }
780
673
 
781
- // --- Class-reference extraction ----------------------------------------------
782
-
674
+ // Names excluded from class-reference scanning: pseudo-types, scalar/special
675
+ // types, and keywords that can sit before `$var`/`::` or after `new` and would
676
+ // otherwise be mistaken for a class reference (`return $x`, `new class`, …).
783
677
  const RESERVED = new Set([
784
- // pseudo-types / class-context keywords
785
678
  'self', 'static', 'parent', 'this',
786
- // scalar + special type names
787
679
  'int', 'integer', 'float', 'double', 'string', 'bool', 'boolean', 'void',
788
680
  'array', 'callable', 'iterable', 'object', 'mixed', 'null', 'false', 'true',
789
681
  '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
682
  'return', 'echo', 'print', 'clone', 'yield', 'throw', 'global', 'and', 'or', 'xor',
794
683
  'as', 'else', 'elseif', 'do', 'class', 'fn', 'match', 'enddeclare', 'endforeach',
795
684
  'endif', 'endswitch', 'endwhile', 'endfor',
796
685
  ])
797
686
 
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.
687
+ // Blank heredocs, comments AND string literals (unlike maskCommentsAndHeredocs,
688
+ // which keeps strings) so class-reference scans don't trip over text inside them.
802
689
  function stripPhp(content) {
803
690
  return maskNonCode(content, false)
804
691
  }
805
692
 
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.
693
+ // Add the FQCN for a class reference `raw` given namespace `ns` and the `use`-alias
694
+ // map, mirroring PHP name resolution: leading `\` = fully-qualified; a first
695
+ // segment matching an alias expands through it; else relative to `ns`.
811
696
  function addRef(set, raw, ns, aliasMap) {
812
697
  const n = raw.trim().replace(/^\?/u, '') // drop a nullable-type marker (`?Foo`)
813
698
  if (!n || !/^\\?[A-Za-z_][\w\\]*$/u.test(n)) return
@@ -827,10 +712,9 @@ function addRef(set, raw, ns, aliasMap) {
827
712
  }
828
713
  }
829
714
 
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.
715
+ // Parse a `use` body (between `use` and `;`) into entries, handling comma lists
716
+ // and group syntax (`Pre\{A, B as C}`). Returns `[{ name, short }]` (short = the
717
+ // alias or last segment).
834
718
  function parseUseBody(body) {
835
719
  const grouped = body.match(/^([\w\\]+\\)\{([\s\S]*)\}$/u)
836
720
  const entries = grouped
@@ -846,34 +730,27 @@ function parseUseBody(body) {
846
730
  return out
847
731
  }
848
732
 
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.
733
+ // Position of the first class/interface/trait/enum declaration -- the end of the
734
+ // import region (a `use` before it is an alias import; after it, an in-body trait
735
+ // use that loads the trait). `::class`/member accesses excluded via the
736
+ // lookbehind. Infinity when there's no class-like declaration.
857
737
  function firstDeclPos(stripped) {
858
738
  const m = stripped.match(/(?<![:\w$\\>])\b(?:class|interface|trait|enum)\b/iu)
859
739
  return m ? m.index : Infinity
860
740
  }
861
741
 
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).
742
+ // Collect the FQCNs a PHP file actually depends on. A `use` import only aliases
743
+ // and loads nothing, so it is NOT a dependency; only real reference sites pull a
744
+ // file in (new/extends/implements/instanceof/catch/`Foo::bar`/attributes/type
745
+ // hints/FQNs, plus in-body `use Trait;`).
868
746
  export function phpClassDependencies(content) {
869
747
  const stripped = stripPhp(content)
870
748
  const nsMatch = stripped.match(/\bnamespace\s+([\w\\]+)/u)
871
749
  const ns = nsMatch ? nsMatch[1].replace(/^\\+|\\+$/gu, '') : ''
872
750
  const set = new Set()
873
751
 
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.
752
+ // `use` before the first declaration builds the alias map; a `use` after it is
753
+ // an in-body trait use, counted as a reference.
877
754
  const declAt = firstDeclPos(stripped)
878
755
  const aliasMap = new Map()
879
756
  const traitRefs = []
@@ -904,20 +781,16 @@ export function phpClassDependencies(content) {
904
781
  if (impl) for (const nm of impl[1].split(',')) add(nm)
905
782
  }
906
783
 
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`.
784
+ // catch (A | B $e): capture the type list up to `$var`. The lazy `[^)]*?` (no
785
+ // second whitespace quantifier) keeps matching linear on adversarial input.
910
786
  for (const m of stripped.matchAll(/\bcatch\s*\(([^)]*?)\$\w+/giu)) {
911
787
  for (const nm of m[1].split('|')) add(nm)
912
788
  }
913
789
 
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.
790
+ // parameter/property type hints: `Type $var`, incl. `?Type`, variadic, by-ref,
791
+ // and union/intersection `A|B`/`A&B` (each member counts). Only one whitespace
792
+ // quantifier is unbounded (inner `\s*` gated behind `[&.]`) to avoid
793
+ // catastrophic backtracking against adversarial input.
921
794
  for (const m of stripped.matchAll(/(?<![\w$\\])([?\w\\|&]+)\s+(?:[&.]+\s*)?\$\w+/giu)) {
922
795
  for (const nm of m[1].split(/[|&]/u)) add(nm)
923
796
  }
@@ -929,11 +802,8 @@ export function phpClassDependencies(content) {
929
802
  return set
930
803
  }
931
804
 
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.
805
+ // Resolve each autoloaded class a file references to a baseDir-relative file that
806
+ // exists and is in `sources`. Best-effort: unresolvable refs skipped.
937
807
  function autoloadEdges(content, autoload, baseDir, sources) {
938
808
  const edges = new Map()
939
809
  if (!autoload) return edges
@@ -944,12 +814,10 @@ function autoloadEdges(content, autoload, baseDir, sources) {
944
814
  return edges
945
815
  }
946
816
 
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.
817
+ // Build `{ sources, resolutions, missing }` from already-loaded PHP sources.
818
+ // Explicit includes are strict (unresolvable -> `missing`); Composer-autoloaded
819
+ // class refs (when `autoload` is given) are best-effort and never added to
820
+ // `missing`. Fallback: a specifier matching a stored key verbatim is accepted.
953
821
  export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
954
822
  const resolutions = new Map()
955
823
  const missing = []
@@ -974,10 +842,9 @@ export function buildPhpTree(sources, { baseDir, autoload = null } = {}) {
974
842
  return { sources, resolutions, missing }
975
843
  }
976
844
 
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.
845
+ // Walk the filesystem from `entries` (plus Composer `files` autoload entries),
846
+ // following resolved includes and autoloaded class refs, reading each file once.
847
+ // Same-wave files are read in parallel; the next wave depends on the previous.
981
848
  export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = null } = {}) {
982
849
  const sources = new Map()
983
850
  const realBase = realpathSync(baseDir)
@@ -987,10 +854,9 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
987
854
  assertRealPathWithinBase(realBase, baseDir, relPath)
988
855
  return [relPath, await readFile(join(baseDir, relPath), 'utf8')]
989
856
  } 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.
857
+ // ENOENT (no file) / EISDIR (specifier resolved to a directory): not a
858
+ // loadable file -- warn and skip rather than crash (buildPhpTree surfaces
859
+ // the unresolved edge for real includes).
994
860
  if (err.code === 'ENOENT' || err.code === 'EISDIR') {
995
861
  console.warn(`[loader.php] Missing import: ${relPath}`)
996
862
  return null
@@ -1002,9 +868,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
1002
868
  const processWave = async (wave) => {
1003
869
  const toLoad = [...new Set(wave)].filter((p) => !sources.has(p))
1004
870
  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.
871
+ // Bounded batches: a wave can be huge (a dynamic include's directory glob),
872
+ // and an unbounded Promise.all would open every file at once and crash EMFILE.
1008
873
  const reads = []
1009
874
  for (let k = 0; k < toLoad.length; k += READ_CONCURRENCY) {
1010
875
  // eslint-disable-next-line no-await-in-loop -- batches are intentionally sequential to cap open files
@@ -1023,9 +888,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
1023
888
  console.warn(`[loader.php] Missing import: ${spec} from ${relPath}`)
1024
889
  }
1025
890
  }
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.
891
+ // Dynamic includes with a static dir prefix: the exact file is known only
892
+ // at runtime, so bundle every `.php` file in that directory as a candidate.
1029
893
  for (const dirSpec of extractPhpImportDirs(content)) {
1030
894
  const dir = resolvePhpDir(dirSpec, relPath, baseDir)
1031
895
  if (!dir) continue
@@ -1033,10 +897,8 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
1033
897
  if (!sources.has(file)) next.push(file)
1034
898
  }
1035
899
  }
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.
900
+ // `.php` path literals outside a require (route/config files passed to a
901
+ // framework). Best-effort: bundle only if they resolve to an existing file.
1040
902
  for (const { spec, anchor } of extractPhpPathRefs(content)) {
1041
903
  const resolved = anchor === 'root'
1042
904
  ? normalizeProjectRel(baseDir, spec)
@@ -1051,8 +913,7 @@ export async function collectPhpFilesFromDisk(baseDir, entries, { autoload = nul
1051
913
  await processWave(next)
1052
914
  }
1053
915
 
1054
- // `files` autoload entries are required unconditionally by Composer, so they
1055
- // are roots of the graph alongside the caller's entries.
916
+ // Composer `files` autoload entries load unconditionally, so they're graph roots too.
1056
917
  await processWave([...entries, ...(autoload?.files ?? [])])
1057
918
  return sources
1058
919
  }