@davidwells/extract-deps 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/dep-graph.js +244 -49
- package/src/get-cjs-exports.js +12 -3
- package/src/get-exports.js +4 -3
- package/src/get-imports.js +7 -4
- package/src/get-requires.js +4 -3
- package/src/utils.js +41 -0
- package/tests/dep-graph-cycles.test.js +111 -0
- package/tests/dep-graph.test.js +29 -1
- package/tests/fixtures/cycle-fixtures/cycle-entry/index.js +2 -0
- package/tests/fixtures/cycle-fixtures/cycle-entry/leaf.js +1 -0
- package/tests/fixtures/cycle-fixtures/cycle-entry/peer.js +3 -0
- package/tests/fixtures/cycle-fixtures/diamond/a.js +2 -0
- package/tests/fixtures/cycle-fixtures/diamond/b.js +2 -0
- package/tests/fixtures/cycle-fixtures/diamond/c.js +2 -0
- package/tests/fixtures/cycle-fixtures/diamond/d.js +1 -0
- package/tests/fixtures/cycle-fixtures/diamond/index.js +3 -0
- package/tests/fixtures/cycle-fixtures/duplicate-edge/index.js +5 -0
- package/tests/fixtures/cycle-fixtures/duplicate-edge/leaf.js +1 -0
- package/tests/fixtures/cycle-fixtures/duplicate-edge/other.js +1 -0
- package/tests/fixtures/cycle-fixtures/duplicate-edge/util.js +2 -0
- package/tests/fixtures/cycle-fixtures/mixed-duplicate/base.mjs +1 -0
- package/tests/fixtures/cycle-fixtures/mixed-duplicate/helper.mjs +2 -0
- package/tests/fixtures/cycle-fixtures/mixed-duplicate/index.mjs +3 -0
- package/tests/fixtures/cycle-fixtures/self-import/index.js +2 -0
- package/tests/fixtures/cycle-fixtures/self-import/leaf.js +1 -0
- package/tests/fixtures/cycle-fixtures/self-import/self.js +3 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/index.js +3 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/leaf.js +1 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/w.js +2 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/x.js +2 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/y.js +2 -0
- package/tests/fixtures/cycle-fixtures/three-cycle/z.js +3 -0
- package/tests/fixtures/cycle-fixtures/two-cycle/a.js +2 -0
- package/tests/fixtures/cycle-fixtures/two-cycle/b.js +2 -0
- package/tests/fixtures/cycle-fixtures/two-cycle/index.js +2 -0
package/package.json
CHANGED
package/src/dep-graph.js
CHANGED
|
@@ -6,6 +6,79 @@ const { resolve: resolveImport } = require('@dual-bundle/import-meta-resolve')
|
|
|
6
6
|
const { extractDepsShared: extractDeps } = require('./index')
|
|
7
7
|
const { isBuiltIn } = require('./utils')
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Parsed deps per file, keyed on mtime + size: callers walk the same shared
|
|
11
|
+
* files for every entry point (and a walk parses each import target twice:
|
|
12
|
+
* to verify the importer's names, then to recurse). Results are read-only.
|
|
13
|
+
* @type {Map<string, { key: string, deps: ReturnType<typeof extractDeps> }>}
|
|
14
|
+
*/
|
|
15
|
+
const parseCache = new Map()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} filePath
|
|
19
|
+
* @returns {ReturnType<typeof extractDeps>}
|
|
20
|
+
*/
|
|
21
|
+
function readDeps(filePath) {
|
|
22
|
+
const stat = fs.statSync(filePath)
|
|
23
|
+
const key = `${stat.mtimeMs}:${stat.size}`
|
|
24
|
+
const cached = parseCache.get(filePath)
|
|
25
|
+
if (cached && cached.key === key) return cached.deps
|
|
26
|
+
const deps = extractDeps(fs.readFileSync(filePath, 'utf8'))
|
|
27
|
+
parseCache.set(filePath, { key, deps })
|
|
28
|
+
return deps
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, string | null>} `${baseDir}\0${importPath}` -> resolved file (or null) */
|
|
32
|
+
const relativeResolveCache = new Map()
|
|
33
|
+
/** @type {Map<string, { url: string } | { error: Error }>} `${parentDir}\0${specifier}` -> package resolution */
|
|
34
|
+
const packageResolveCache = new Map()
|
|
35
|
+
/** @type {Map<string, string>} path -> realpath */
|
|
36
|
+
const realpathCache = new Map()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* fs.realpathSync, memoized per path.
|
|
40
|
+
* @param {string} filePath
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function realpath(filePath) {
|
|
44
|
+
let real = realpathCache.get(filePath)
|
|
45
|
+
if (real === undefined) {
|
|
46
|
+
real = fs.realpathSync(filePath)
|
|
47
|
+
realpathCache.set(filePath, real)
|
|
48
|
+
}
|
|
49
|
+
return real
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* resolveImport (import-meta-resolve) memoized per importing directory: a
|
|
54
|
+
* bare specifier resolves the same from every file in one directory.
|
|
55
|
+
* @param {string} specifier
|
|
56
|
+
* @param {string} parentPath - Importing file
|
|
57
|
+
* @returns {string} resolved URL
|
|
58
|
+
*/
|
|
59
|
+
function resolvePackageImport(specifier, parentPath) {
|
|
60
|
+
const key = `${path.dirname(parentPath)}\0${specifier}`
|
|
61
|
+
let cached = packageResolveCache.get(key)
|
|
62
|
+
if (!cached) {
|
|
63
|
+
try {
|
|
64
|
+
cached = { url: resolveImport(specifier, pathToFileURL(parentPath).href) }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
cached = { error: /** @type {Error} */ (error) }
|
|
67
|
+
}
|
|
68
|
+
packageResolveCache.set(key, cached)
|
|
69
|
+
}
|
|
70
|
+
if ('error' in cached) throw cached.error
|
|
71
|
+
return cached.url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Drop memoized parses and resolutions (e.g. between runs in a long-lived process). */
|
|
75
|
+
function clearDepGraphCache() {
|
|
76
|
+
parseCache.clear()
|
|
77
|
+
relativeResolveCache.clear()
|
|
78
|
+
packageResolveCache.clear()
|
|
79
|
+
realpathCache.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
9
82
|
function fsExistsSync(path) {
|
|
10
83
|
try {
|
|
11
84
|
fs.accessSync(path, fs.constants.F_OK)
|
|
@@ -17,7 +90,10 @@ function fsExistsSync(path) {
|
|
|
17
90
|
|
|
18
91
|
function isFileSync(filePath) {
|
|
19
92
|
try {
|
|
20
|
-
|
|
93
|
+
// Most probes miss (each import tries several extensions): a missing path returns
|
|
94
|
+
// undefined instead of building and throwing an ENOENT error.
|
|
95
|
+
const stat = fs.statSync(filePath, { throwIfNoEntry: false })
|
|
96
|
+
return stat ? stat.isFile() : false
|
|
21
97
|
} catch (error) {
|
|
22
98
|
return false
|
|
23
99
|
}
|
|
@@ -30,6 +106,21 @@ function isFileSync(filePath) {
|
|
|
30
106
|
* @returns {string|null} - Resolved file path or null if not found
|
|
31
107
|
*/
|
|
32
108
|
function resolveImportPath(baseDir, importPath) {
|
|
109
|
+
const key = `${baseDir}\0${importPath}`
|
|
110
|
+
let resolved = relativeResolveCache.get(key)
|
|
111
|
+
if (resolved === undefined) {
|
|
112
|
+
resolved = resolveImportPathUncached(baseDir, importPath)
|
|
113
|
+
relativeResolveCache.set(key, resolved)
|
|
114
|
+
}
|
|
115
|
+
return resolved
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} baseDir
|
|
120
|
+
* @param {string} importPath
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
function resolveImportPathUncached(baseDir, importPath) {
|
|
33
124
|
const possibleExtensions = ['.js', '.mjs', '.ts', '.json', '']
|
|
34
125
|
|
|
35
126
|
// Try direct file resolution (must be a file, not directory)
|
|
@@ -198,7 +289,7 @@ function buildGraphRecursively(
|
|
|
198
289
|
visited = new Set(),
|
|
199
290
|
) {
|
|
200
291
|
// Avoid infinite loops
|
|
201
|
-
const normalizedPath =
|
|
292
|
+
const normalizedPath = realpath(filePath)
|
|
202
293
|
if (visited.has(normalizedPath)) {
|
|
203
294
|
return
|
|
204
295
|
}
|
|
@@ -210,8 +301,7 @@ function buildGraphRecursively(
|
|
|
210
301
|
}
|
|
211
302
|
|
|
212
303
|
// Read and extract deps from this file
|
|
213
|
-
const
|
|
214
|
-
const deps = extractDeps(content)
|
|
304
|
+
const deps = readDeps(filePath)
|
|
215
305
|
|
|
216
306
|
// Collect all relative dependencies
|
|
217
307
|
const relativeDeps = []
|
|
@@ -255,15 +345,14 @@ function buildGraphRecursively(
|
|
|
255
345
|
|
|
256
346
|
// Add edge to graph if building
|
|
257
347
|
if (graph !== null) {
|
|
258
|
-
const normalizedResolvedPath =
|
|
348
|
+
const normalizedResolvedPath = realpath(resolvedPath)
|
|
259
349
|
graph.get(normalizedPath).push(normalizedResolvedPath)
|
|
260
350
|
}
|
|
261
351
|
|
|
262
352
|
// Verify imports if verification is enabled
|
|
263
353
|
if (verify) {
|
|
264
354
|
// Extract deps from the imported file
|
|
265
|
-
const
|
|
266
|
-
const targetDeps = extractDeps(targetContent)
|
|
355
|
+
const targetDeps = readDeps(resolvedPath)
|
|
267
356
|
|
|
268
357
|
// Verify the imported values are exported
|
|
269
358
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
@@ -282,8 +371,6 @@ function buildGraphRecursively(
|
|
|
282
371
|
|
|
283
372
|
// Verify node_modules dependencies if enabled (skip built-ins)
|
|
284
373
|
if (verifyNodeModules && nodeModuleDeps.length > 0) {
|
|
285
|
-
const parentURL = pathToFileURL(filePath).href
|
|
286
|
-
|
|
287
374
|
for (const dep of nodeModuleDeps) {
|
|
288
375
|
const { from, line } = dep
|
|
289
376
|
|
|
@@ -295,7 +382,7 @@ function buildGraphRecursively(
|
|
|
295
382
|
let resolvedPath
|
|
296
383
|
try {
|
|
297
384
|
// resolveImport throws if file doesn't exist
|
|
298
|
-
const resolved =
|
|
385
|
+
const resolved = resolvePackageImport(from, filePath)
|
|
299
386
|
resolvedPath = fileURLToPath(resolved)
|
|
300
387
|
} catch (error) {
|
|
301
388
|
const err = new Error(
|
|
@@ -313,8 +400,7 @@ function buildGraphRecursively(
|
|
|
313
400
|
|
|
314
401
|
// Verify import/export styles match (only needed for ESM imports)
|
|
315
402
|
if (dep.isESM) {
|
|
316
|
-
const
|
|
317
|
-
const targetDeps = extractDeps(targetContent)
|
|
403
|
+
const targetDeps = readDeps(resolvedPath)
|
|
318
404
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
319
405
|
}
|
|
320
406
|
}
|
|
@@ -365,7 +451,149 @@ function depGraph(filePath, options = {}) {
|
|
|
365
451
|
|
|
366
452
|
|
|
367
453
|
/**
|
|
368
|
-
*
|
|
454
|
+
* Order every file in the graph so dependencies come before dependents, each file once.
|
|
455
|
+
*
|
|
456
|
+
* Kahn's algorithm over the strongly connected components (Tarjan) of the file graph:
|
|
457
|
+
* an import cycle (or self-import) is one component, emitted as a unit with its members
|
|
458
|
+
* in discovery order. Duplicate edges (one file importing a module twice) count once.
|
|
459
|
+
* For an acyclic graph every component is a single file, and the queue seeding and
|
|
460
|
+
* dependent visiting orders match a plain per-file Kahn's sort over the graph's
|
|
461
|
+
* insertion order. O(V + E).
|
|
462
|
+
*
|
|
463
|
+
* @param {Map<string, string[]>} graph - file -> deps (file paths and package names)
|
|
464
|
+
* @returns {string[]}
|
|
465
|
+
*/
|
|
466
|
+
function buildOrder(graph) {
|
|
467
|
+
const nodes = Array.from(graph.keys())
|
|
468
|
+
const n = nodes.length
|
|
469
|
+
/** @type {Map<string, number>} */
|
|
470
|
+
const indexOf = new Map()
|
|
471
|
+
for (let i = 0; i < n; i++) indexOf.set(nodes[i], i)
|
|
472
|
+
|
|
473
|
+
// Unique file deps per node (first-occurrence order) and reverse edges. Dependents
|
|
474
|
+
// are appended while walking nodes in graph order, so each reverse list is in graph order.
|
|
475
|
+
/** @type {number[][]} */
|
|
476
|
+
const deps = new Array(n)
|
|
477
|
+
/** @type {number[][]} */
|
|
478
|
+
const dependents = new Array(n)
|
|
479
|
+
for (let i = 0; i < n; i++) dependents[i] = []
|
|
480
|
+
const seenStamp = new Int32Array(n).fill(-1)
|
|
481
|
+
for (let i = 0; i < n; i++) {
|
|
482
|
+
const out = []
|
|
483
|
+
for (const dep of graph.get(nodes[i])) {
|
|
484
|
+
const j = indexOf.get(dep) // package names (and unvisited paths) are not nodes
|
|
485
|
+
if (j === undefined || seenStamp[j] === i) continue
|
|
486
|
+
seenStamp[j] = i
|
|
487
|
+
out.push(j)
|
|
488
|
+
dependents[j].push(i)
|
|
489
|
+
}
|
|
490
|
+
deps[i] = out
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Tarjan's SCC, iterative (deep import chains must not overflow the call stack)
|
|
494
|
+
const UNVISITED = -1
|
|
495
|
+
const order = new Int32Array(n).fill(UNVISITED)
|
|
496
|
+
const low = new Int32Array(n)
|
|
497
|
+
const onStack = new Uint8Array(n)
|
|
498
|
+
const comp = new Int32Array(n)
|
|
499
|
+
const sccStack = []
|
|
500
|
+
const callStack = [] // [node, next dep position]
|
|
501
|
+
let counter = 0
|
|
502
|
+
let compCount = 0
|
|
503
|
+
for (let root = 0; root < n; root++) {
|
|
504
|
+
if (order[root] !== UNVISITED) continue
|
|
505
|
+
callStack.push(root, 0)
|
|
506
|
+
order[root] = low[root] = counter++
|
|
507
|
+
sccStack.push(root)
|
|
508
|
+
onStack[root] = 1
|
|
509
|
+
while (callStack.length > 0) {
|
|
510
|
+
const top = callStack.length - 2
|
|
511
|
+
const v = callStack[top]
|
|
512
|
+
const edges = deps[v]
|
|
513
|
+
const pos = callStack[top + 1]
|
|
514
|
+
if (pos < edges.length) {
|
|
515
|
+
callStack[top + 1] = pos + 1
|
|
516
|
+
const w = edges[pos]
|
|
517
|
+
if (order[w] === UNVISITED) {
|
|
518
|
+
order[w] = low[w] = counter++
|
|
519
|
+
sccStack.push(w)
|
|
520
|
+
onStack[w] = 1
|
|
521
|
+
callStack.push(w, 0)
|
|
522
|
+
} else if (onStack[w] && order[w] < low[v]) {
|
|
523
|
+
low[v] = order[w]
|
|
524
|
+
}
|
|
525
|
+
continue
|
|
526
|
+
}
|
|
527
|
+
callStack.length = top
|
|
528
|
+
if (top > 0) {
|
|
529
|
+
const parent = callStack[top - 2]
|
|
530
|
+
if (low[v] < low[parent]) low[parent] = low[v]
|
|
531
|
+
}
|
|
532
|
+
if (low[v] === order[v]) {
|
|
533
|
+
let w
|
|
534
|
+
do {
|
|
535
|
+
w = sccStack.pop()
|
|
536
|
+
onStack[w] = 0
|
|
537
|
+
comp[w] = compCount
|
|
538
|
+
} while (w !== v)
|
|
539
|
+
compCount++
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Members per component in graph (discovery) order
|
|
545
|
+
/** @type {number[][]} */
|
|
546
|
+
const members = new Array(compCount)
|
|
547
|
+
for (let c = 0; c < compCount; c++) members[c] = []
|
|
548
|
+
for (let i = 0; i < n; i++) members[comp[i]].push(i)
|
|
549
|
+
|
|
550
|
+
// Component in-degree: distinct other components it depends on
|
|
551
|
+
const compInDegree = new Int32Array(compCount)
|
|
552
|
+
const compStamp = new Int32Array(compCount).fill(-1)
|
|
553
|
+
for (let c = 0; c < compCount; c++) {
|
|
554
|
+
for (const v of members[c]) {
|
|
555
|
+
for (const w of deps[v]) {
|
|
556
|
+
const cw = comp[w]
|
|
557
|
+
if (cw === c || compStamp[cw] === c) continue
|
|
558
|
+
compStamp[cw] = c
|
|
559
|
+
compInDegree[c]++
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Kahn's over components, seeded in graph order
|
|
565
|
+
const queue = []
|
|
566
|
+
const queued = new Uint8Array(compCount)
|
|
567
|
+
for (let i = 0; i < n; i++) {
|
|
568
|
+
const c = comp[i]
|
|
569
|
+
if (compInDegree[c] === 0 && !queued[c]) {
|
|
570
|
+
queued[c] = 1
|
|
571
|
+
queue.push(c)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const result = []
|
|
575
|
+
compStamp.fill(-1)
|
|
576
|
+
for (let head = 0; head < queue.length; head++) {
|
|
577
|
+
const c = queue[head]
|
|
578
|
+
for (const v of members[c]) result.push(nodes[v])
|
|
579
|
+
for (const v of members[c]) {
|
|
580
|
+
for (const d of dependents[v]) {
|
|
581
|
+
const cd = comp[d]
|
|
582
|
+
if (cd === c || compStamp[cd] === c) continue
|
|
583
|
+
compStamp[cd] = c
|
|
584
|
+
if (--compInDegree[cd] === 0) {
|
|
585
|
+
queued[cd] = 1
|
|
586
|
+
queue.push(cd)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return result
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Get all dependency file paths in build order (dependencies first), deduplicated.
|
|
596
|
+
* Files in an import cycle are kept together, in discovery order.
|
|
369
597
|
* @param {string} filePath - Entry file path
|
|
370
598
|
* @param {object} options - Options to pass to depGraph
|
|
371
599
|
* @param {boolean} [options.verifyGraph] - If true, verify imports/exports. Default: true
|
|
@@ -409,42 +637,8 @@ function resolveDepPaths(filePath, options = {}) {
|
|
|
409
637
|
}
|
|
410
638
|
}
|
|
411
639
|
|
|
412
|
-
// Calculate in-degree (number of file dependencies) for each file
|
|
413
|
-
const inDegree = new Map()
|
|
414
|
-
for (const [file, deps] of graph) {
|
|
415
|
-
// Only count file deps, not node_modules
|
|
416
|
-
const fileDeps = deps.filter(d => d.startsWith('/'))
|
|
417
|
-
inDegree.set(file, fileDeps.length)
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Queue of files with no dependencies (can be processed first)
|
|
421
|
-
const queue = []
|
|
422
|
-
for (const [file, degree] of inDegree) {
|
|
423
|
-
if (degree === 0) {
|
|
424
|
-
queue.push(file)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const buildOrder = []
|
|
429
|
-
|
|
430
|
-
while (queue.length > 0) {
|
|
431
|
-
const file = queue.shift()
|
|
432
|
-
buildOrder.push(file)
|
|
433
|
-
|
|
434
|
-
// Find all files that depend on this file and decrement their in-degree
|
|
435
|
-
for (const [dependent, deps] of graph) {
|
|
436
|
-
if (deps.includes(file)) {
|
|
437
|
-
const newDegree = inDegree.get(dependent) - 1
|
|
438
|
-
inDegree.set(dependent, newDegree)
|
|
439
|
-
if (newDegree === 0) {
|
|
440
|
-
queue.push(dependent)
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
640
|
const result = {
|
|
447
|
-
files: buildOrder,
|
|
641
|
+
files: buildOrder(graph),
|
|
448
642
|
nodeModules: Array.from(nodeModulesSet),
|
|
449
643
|
builtIns: Array.from(builtInsSet)
|
|
450
644
|
}
|
|
@@ -470,5 +664,6 @@ if (require.main === module) {
|
|
|
470
664
|
|
|
471
665
|
module.exports = {
|
|
472
666
|
depGraph,
|
|
473
|
-
resolveDepPaths
|
|
667
|
+
resolveDepPaths,
|
|
668
|
+
clearDepGraphCache
|
|
474
669
|
}
|
package/src/get-cjs-exports.js
CHANGED
|
@@ -34,16 +34,25 @@ function stripComments(code) {
|
|
|
34
34
|
return result
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Every scanner below is gated on one of these in the sanitized text. Sanitizing only
|
|
38
|
+
// blanks characters to spaces (length-preserving), so a needle without whitespace that
|
|
39
|
+
// isn't in the source can't be in the sanitized text either.
|
|
40
|
+
const CJS_NEEDLES = ['exports', 'Object.assign', 'Object.defineProperty', 'Object.keys(', '__exportStar', '__reExport']
|
|
41
|
+
|
|
37
42
|
function getCjsExports(code) {
|
|
43
|
+
// Most ESM/TS files mention none of them: skip the sanitize pass entirely.
|
|
44
|
+
if (!CJS_NEEDLES.some(needle => code.includes(needle))) return []
|
|
38
45
|
// Single tokenizer-based pass that blanks comments and code-bearing
|
|
39
46
|
// strings/templates together. Length is preserved so the position-based
|
|
40
47
|
// logic below (which slices into the original `code` for full export
|
|
41
48
|
// values) keeps working off the same indices.
|
|
42
49
|
const sanitizedCode = stripCommentsAndStrings(code)
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
51
|
+
// Their regexes need `module.exports` / `exports` in the sanitized text.
|
|
52
|
+
const hasModuleExports = sanitizedCode.includes('module.exports')
|
|
53
|
+
const assignmentExports = hasModuleExports ? getModuleExportsAssignment(code, sanitizedCode) : []
|
|
54
|
+
const modulePropertyExports = hasModuleExports ? getModuleExportsProperty(code, sanitizedCode) : []
|
|
55
|
+
const propertyExports = sanitizedCode.includes('exports') ? getExportsProperty(code, sanitizedCode) : []
|
|
47
56
|
|
|
48
57
|
// Quick pre-checks to avoid expensive regex operations when not needed
|
|
49
58
|
const objectAssignExports = sanitizedCode.includes('Object.assign')
|
package/src/get-exports.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { getNames, extractExportName,
|
|
1
|
+
const { getNames, extractExportName, stripCommentsThenStrings } = require('./utils')
|
|
2
2
|
|
|
3
3
|
// https://regex101.com/r/example/1
|
|
4
4
|
const EXPORT_DEFAULT_REGEX = /export\s+default\s+(?:(?:async\s+)?function\s+([\w$]+)|class\s+([\w$]+)|([\w$]+|\([^)]*\)\s*=>|{[\s\S]*?}|\[[\s\S]*?\]|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|[\w$]+(?:\s*[+\-*/%]\s*[\w$]+)*))/gm
|
|
@@ -16,12 +16,13 @@ const EXPORT_TYPE_REGEX = /export\s+(?:type|interface)\s+([\w$]+)/gm
|
|
|
16
16
|
const EXPORT_ASSERTION_REGEX = /export\s+(?:["'\s]*([\w*{}\n\r\t, ]+)from\s*)?["'\s](.*[@\w_-]+)["'\s]+(assert|with)\s*\{([^}]+)\};?$/gm
|
|
17
17
|
|
|
18
18
|
function getExports(code = '', options = {}) {
|
|
19
|
+
// Every pattern below needs the word `export`; stripping never adds one.
|
|
20
|
+
if (!code.includes('export')) return []
|
|
19
21
|
// Comments first, then strings — order matters. Running stripStringContents
|
|
20
22
|
// alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
|
|
21
23
|
// with a later quote and shift subsequent backtick pairing, which could
|
|
22
24
|
// swallow real `export` declarations a few lines later.
|
|
23
|
-
code =
|
|
24
|
-
code = stripStringContents(code)
|
|
25
|
+
code = stripCommentsThenStrings(code)
|
|
25
26
|
const defaultExports = getDefaultExports(code)
|
|
26
27
|
const namedExports = getNamedExports(code)
|
|
27
28
|
const namedDeclarationExports = getNamedDeclarationExports(code)
|
package/src/get-imports.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames,
|
|
1
|
+
const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, stripCommentsThenStrings } = require('./utils')
|
|
2
2
|
|
|
3
3
|
// https://regex101.com/r/cfEmlm/1
|
|
4
4
|
// Path excludes quotes, newlines, and `;` so multi-statement lines like
|
|
@@ -37,14 +37,17 @@ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)
|
|
|
37
37
|
const ASSERTION_IMPORT_REGEX = /(?:^|\n)\s*import\s+(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["']\s+(assert|with)\s*\{([^}]+)\}/gm
|
|
38
38
|
|
|
39
39
|
function getImports(code = '', options = {}) {
|
|
40
|
+
// Every pattern below needs the word `import`; stripping never adds one.
|
|
41
|
+
if (!code.includes('import')) return []
|
|
40
42
|
// Strip comments FIRST so apostrophes/backticks inside JSDoc text get
|
|
41
43
|
// removed before `stripStringContents` looks for string literals.
|
|
42
44
|
// (Running the two in the opposite order let comment chars masquerade as
|
|
43
45
|
// string openers and swallow real `import`/`export` lines further down.)
|
|
44
|
-
code =
|
|
45
|
-
code = stripStringContents(code)
|
|
46
|
+
code = stripCommentsThenStrings(code)
|
|
46
47
|
const staticImports = getStaticImports(code)
|
|
47
|
-
|
|
48
|
+
// Every dynamic-import match contains `import(` (most files have none; the regex's
|
|
49
|
+
// optional declaration prefix makes a miss scan the whole text).
|
|
50
|
+
const dynamicImports = code.includes('import(') ? getDynamicImports(code) : []
|
|
48
51
|
if (options.includeAssertionImports) {
|
|
49
52
|
const assertionImports = getAssertionImports(code)
|
|
50
53
|
return staticImports.concat(dynamicImports).concat(assertionImports).sort((a, b) => a.index - b.index)
|
package/src/get-requires.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { isBuiltIn, isRelativeImport, getNames,
|
|
1
|
+
const { isBuiltIn, isRelativeImport, getNames, stripCommentsThenStrings } = require('./utils')
|
|
2
2
|
|
|
3
3
|
// Updated regex to exclude ES6 dynamic imports
|
|
4
4
|
// require() must always be present — previous version made it optional, causing false matches
|
|
@@ -105,12 +105,13 @@ function getStaticRequires(targetText = '') {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function getRequires(code) {
|
|
108
|
+
// Both scanners need the word `require`; stripping never adds one.
|
|
109
|
+
if (!code.includes('require')) return []
|
|
108
110
|
// Strip comments first, then strings — order matters. `stripComments`
|
|
109
111
|
// alone would mis-eat `//` inside strings; running it first instead means
|
|
110
112
|
// comment chars (incl. apostrophes/backticks inside JSDoc) disappear
|
|
111
113
|
// before `stripStringContents` looks for real string literals.
|
|
112
|
-
code =
|
|
113
|
-
code = stripStringContents(code)
|
|
114
|
+
code = stripCommentsThenStrings(code)
|
|
114
115
|
const staticRequires = getStaticRequires(code)
|
|
115
116
|
const dynamicRequires = getDynamicRequires(code)
|
|
116
117
|
return staticRequires.concat(dynamicRequires).sort((a, b) => a.index - b.index)
|
package/src/utils.js
CHANGED
|
@@ -37,8 +37,18 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
|
|
|
37
37
|
function stripStringContents(code) {
|
|
38
38
|
let output = ''
|
|
39
39
|
let i = 0
|
|
40
|
+
const special = /[/"'`]/g
|
|
40
41
|
|
|
41
42
|
while (i < code.length) {
|
|
43
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
44
|
+
special.lastIndex = i
|
|
45
|
+
const next = special.exec(code)
|
|
46
|
+
const stop = next ? next.index : code.length
|
|
47
|
+
if (stop > i) {
|
|
48
|
+
output += code.slice(i, stop)
|
|
49
|
+
i = stop
|
|
50
|
+
if (i >= code.length) break
|
|
51
|
+
}
|
|
42
52
|
const char = code[i]
|
|
43
53
|
|
|
44
54
|
if (char === '/' && isRegexLiteralStart(code, i)) {
|
|
@@ -218,8 +228,18 @@ function appendSkippedComment(output, commentText, preserveLength) {
|
|
|
218
228
|
function stripCommentsImpl(code, { preserveLength = false } = {}) {
|
|
219
229
|
let output = ''
|
|
220
230
|
let i = 0
|
|
231
|
+
const special = /[/"'`]/g
|
|
221
232
|
|
|
222
233
|
while (i < code.length) {
|
|
234
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
235
|
+
special.lastIndex = i
|
|
236
|
+
const found = special.exec(code)
|
|
237
|
+
const stop = found ? found.index : code.length
|
|
238
|
+
if (stop > i) {
|
|
239
|
+
output += code.slice(i, stop)
|
|
240
|
+
i = stop
|
|
241
|
+
if (i >= code.length) break
|
|
242
|
+
}
|
|
223
243
|
const char = code[i]
|
|
224
244
|
const next = code[i + 1]
|
|
225
245
|
|
|
@@ -271,6 +291,26 @@ function stripComments(code) {
|
|
|
271
291
|
return stripCommentsImpl(code)
|
|
272
292
|
}
|
|
273
293
|
|
|
294
|
+
// Last input/output of stripCommentsThenStrings: one parse runs the import,
|
|
295
|
+
// require and export scanners over the same source text in a row.
|
|
296
|
+
let lastStripInput = null
|
|
297
|
+
let lastStripOutput = ''
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `stripStringContents(stripComments(code))`, the prelude of the import, require
|
|
301
|
+
* and export scanners (comments first, then strings: see getImports). Pure in
|
|
302
|
+
* `code`; the latest result is kept for the next scanner of the same text.
|
|
303
|
+
* @param {string} code
|
|
304
|
+
* @returns {string}
|
|
305
|
+
*/
|
|
306
|
+
function stripCommentsThenStrings(code) {
|
|
307
|
+
if (code === lastStripInput) return lastStripOutput
|
|
308
|
+
const output = stripStringContents(stripCommentsImpl(code))
|
|
309
|
+
lastStripInput = code
|
|
310
|
+
lastStripOutput = output
|
|
311
|
+
return output
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
/**
|
|
275
315
|
* Check if a module specifier is a Node.js built-in
|
|
276
316
|
* @param {string} moduleName - The module specifier (e.g., 'fs', 'node:fs/promises')
|
|
@@ -484,6 +524,7 @@ module.exports = {
|
|
|
484
524
|
stripComments,
|
|
485
525
|
stripStringContents,
|
|
486
526
|
stripCommentsAndStrings,
|
|
527
|
+
stripCommentsThenStrings,
|
|
487
528
|
isBuiltIn,
|
|
488
529
|
isRelativeImport,
|
|
489
530
|
isRelativeImportWithQuotes,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// resolveDepPaths must return every reachable file exactly once, including
|
|
2
|
+
// import cycles, files that depend on a cycle, self-imports and files that
|
|
3
|
+
// import the same module more than once (duplicate edges).
|
|
4
|
+
const { test } = require('uvu')
|
|
5
|
+
const assert = require('uvu/assert')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const { depGraph, resolveDepPaths } = require('../src/dep-graph.js')
|
|
8
|
+
|
|
9
|
+
const FIXTURES = path.resolve(__dirname, './fixtures/cycle-fixtures')
|
|
10
|
+
|
|
11
|
+
/** @param {string} rel */
|
|
12
|
+
const fixture = (rel) => path.join(FIXTURES, rel)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve deps and return basenames (fixtures use unique names per directory)
|
|
16
|
+
* @param {string} entry
|
|
17
|
+
*/
|
|
18
|
+
function names(entry) {
|
|
19
|
+
const result = resolveDepPaths(entry, { returnErrors: true })
|
|
20
|
+
assert.equal(result.errors, [], 'fixture should resolve without errors')
|
|
21
|
+
return result.files.map(f => path.basename(f))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every file in the graph (= everything reachable from the entry) appears exactly once
|
|
26
|
+
* @param {string} entry
|
|
27
|
+
*/
|
|
28
|
+
function assertComplete(entry) {
|
|
29
|
+
const graph = depGraph(entry)
|
|
30
|
+
const { files } = resolveDepPaths(entry)
|
|
31
|
+
assert.is(new Set(files).size, files.length, 'no duplicates')
|
|
32
|
+
assert.equal([...files].sort(), [...graph.keys()].sort(), 'files equal the reachable set')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('2-file cycle: both cycle members and their dependent are returned', () => {
|
|
36
|
+
const entry = fixture('two-cycle/index.js')
|
|
37
|
+
assertComplete(entry)
|
|
38
|
+
assert.equal(names(entry), ['a.js', 'b.js', 'index.js'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('cycle through the entry file: entry and its cycle peer are returned', () => {
|
|
42
|
+
const entry = fixture('cycle-entry/index.js')
|
|
43
|
+
assertComplete(entry)
|
|
44
|
+
assert.equal(names(entry), ['leaf.js', 'index.js', 'peer.js'])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('3-file cycle with a dependent outside it and a leaf below it', () => {
|
|
48
|
+
const entry = fixture('three-cycle/index.js')
|
|
49
|
+
assertComplete(entry)
|
|
50
|
+
// leaf first (dependency of the cycle), cycle members in discovery order,
|
|
51
|
+
// then w (depends on the cycle), then the entry
|
|
52
|
+
assert.equal(names(entry), ['leaf.js', 'x.js', 'y.js', 'z.js', 'w.js', 'index.js'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('self-import does not drop the file or its dependents', () => {
|
|
56
|
+
const entry = fixture('self-import/index.js')
|
|
57
|
+
assertComplete(entry)
|
|
58
|
+
assert.equal(names(entry), ['leaf.js', 'self.js', 'index.js'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('same module required twice (same and different specifiers)', () => {
|
|
62
|
+
const entry = fixture('duplicate-edge/index.js')
|
|
63
|
+
assertComplete(entry)
|
|
64
|
+
assert.equal(names(entry), ['leaf.js', 'other.js', 'util.js', 'index.js'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('same module imported (ESM) and required (CJS) in one file', () => {
|
|
68
|
+
const entry = fixture('mixed-duplicate/index.mjs')
|
|
69
|
+
assertComplete(entry)
|
|
70
|
+
assert.equal(names(entry), ['base.mjs', 'helper.mjs', 'index.mjs'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('diamond (acyclic): shared dependency once, dependencies before dependents', () => {
|
|
74
|
+
const entry = fixture('diamond/index.js')
|
|
75
|
+
assertComplete(entry)
|
|
76
|
+
assert.equal(names(entry), ['d.js', 'c.js', 'a.js', 'b.js', 'index.js'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('acyclic graphs keep dependencies-first order', () => {
|
|
80
|
+
for (const rel of ['diamond/index.js', 'duplicate-edge/index.js', 'mixed-duplicate/index.mjs']) {
|
|
81
|
+
const entry = fixture(rel)
|
|
82
|
+
const graph = depGraph(entry)
|
|
83
|
+
const { files } = resolveDepPaths(entry)
|
|
84
|
+
const pos = new Map(files.map((f, i) => [f, i]))
|
|
85
|
+
for (const [file, deps] of graph) {
|
|
86
|
+
for (const dep of deps) {
|
|
87
|
+
assert.ok(pos.get(dep) < pos.get(file), `${path.basename(dep)} before ${path.basename(file)} in ${rel}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('files outside a cycle still come after everything they depend on', () => {
|
|
94
|
+
const entry = fixture('three-cycle/index.js')
|
|
95
|
+
const { files } = resolveDepPaths(entry)
|
|
96
|
+
const pos = new Map(files.map(f => [path.basename(f), files.indexOf(f)]))
|
|
97
|
+
for (const member of ['x.js', 'y.js', 'z.js']) {
|
|
98
|
+
assert.ok(pos.get(member) < pos.get('w.js'), `${member} before w.js`)
|
|
99
|
+
assert.ok(pos.get(member) > pos.get('leaf.js'), `leaf.js before ${member}`)
|
|
100
|
+
}
|
|
101
|
+
assert.is(files[files.length - 1], entry, 'entry last when it is not in a cycle')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('output is deterministic across calls', () => {
|
|
105
|
+
for (const rel of ['two-cycle/index.js', 'three-cycle/index.js', 'cycle-entry/index.js', 'self-import/index.js']) {
|
|
106
|
+
const entry = fixture(rel)
|
|
107
|
+
assert.equal(resolveDepPaths(entry).files, resolveDepPaths(entry).files)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test.run()
|
package/tests/dep-graph.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const { test } = require('uvu')
|
|
3
3
|
const assert = require('uvu/assert')
|
|
4
4
|
const path = require('path')
|
|
5
|
-
const { depGraph, resolveDepPaths } = require('../src/dep-graph.js')
|
|
5
|
+
const { depGraph, resolveDepPaths, clearDepGraphCache } = require('../src/dep-graph.js')
|
|
6
6
|
|
|
7
7
|
const CJS_FIXTURE_PATH = path.resolve(__dirname, './fixtures/cjs-example/index.js')
|
|
8
8
|
const ESM_FIXTURE_PATH = path.resolve(__dirname, './fixtures/esm-fixture/index.js')
|
|
@@ -727,4 +727,32 @@ test('TS regex exports: resolveDepPaths with returnErrors should have zero error
|
|
|
727
727
|
assert.is(result.errors.length, 0, 'Should have zero verification errors')
|
|
728
728
|
})
|
|
729
729
|
|
|
730
|
+
test('memoized walks see edited files and new files after clearDepGraphCache', () => {
|
|
731
|
+
const fs = require('fs')
|
|
732
|
+
const os = require('os')
|
|
733
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dep-graph-memo-')))
|
|
734
|
+
const entry = path.join(dir, 'index.js')
|
|
735
|
+
fs.writeFileSync(entry, "const a = require('./a')\nmodule.exports = a")
|
|
736
|
+
fs.writeFileSync(path.join(dir, 'a.js'), 'module.exports = 1')
|
|
737
|
+
try {
|
|
738
|
+
assert.equal(resolveDepPaths(entry).files.map(f => path.basename(f)), ['a.js', 'index.js'])
|
|
739
|
+
// Edit: a new import. mtime moves, so the file is parsed again.
|
|
740
|
+
fs.writeFileSync(path.join(dir, 'b.js'), 'module.exports = 2')
|
|
741
|
+
fs.writeFileSync(entry, "const a = require('./a')\nconst b = require('./b')\nmodule.exports = a + b")
|
|
742
|
+
const later = new Date(Date.now() + 5000)
|
|
743
|
+
fs.utimesSync(entry, later, later)
|
|
744
|
+
assert.equal(resolveDepPaths(entry).files.map(f => path.basename(f)).sort(), ['a.js', 'b.js', 'index.js'])
|
|
745
|
+
// A missing import stays missing until the cache is cleared.
|
|
746
|
+
fs.writeFileSync(entry, "const c = require('./c')\nmodule.exports = c")
|
|
747
|
+
const later2 = new Date(Date.now() + 10000)
|
|
748
|
+
fs.utimesSync(entry, later2, later2)
|
|
749
|
+
assert.is(resolveDepPaths(entry, { returnErrors: true }).errors.length, 1)
|
|
750
|
+
fs.writeFileSync(path.join(dir, 'c.js'), 'module.exports = 3')
|
|
751
|
+
clearDepGraphCache()
|
|
752
|
+
assert.is(resolveDepPaths(entry, { returnErrors: true }).errors.length, 0)
|
|
753
|
+
} finally {
|
|
754
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
|
|
730
758
|
test.run()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { leaf: true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { d: true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { leaf: true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { other: true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const base = 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { leaf: true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { leaf: true }
|