@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/dep-graph.js +244 -49
  3. package/src/get-cjs-exports.js +12 -3
  4. package/src/get-exports.js +4 -3
  5. package/src/get-imports.js +7 -4
  6. package/src/get-requires.js +4 -3
  7. package/src/utils.js +41 -0
  8. package/tests/dep-graph-cycles.test.js +111 -0
  9. package/tests/dep-graph.test.js +29 -1
  10. package/tests/fixtures/cycle-fixtures/cycle-entry/index.js +2 -0
  11. package/tests/fixtures/cycle-fixtures/cycle-entry/leaf.js +1 -0
  12. package/tests/fixtures/cycle-fixtures/cycle-entry/peer.js +3 -0
  13. package/tests/fixtures/cycle-fixtures/diamond/a.js +2 -0
  14. package/tests/fixtures/cycle-fixtures/diamond/b.js +2 -0
  15. package/tests/fixtures/cycle-fixtures/diamond/c.js +2 -0
  16. package/tests/fixtures/cycle-fixtures/diamond/d.js +1 -0
  17. package/tests/fixtures/cycle-fixtures/diamond/index.js +3 -0
  18. package/tests/fixtures/cycle-fixtures/duplicate-edge/index.js +5 -0
  19. package/tests/fixtures/cycle-fixtures/duplicate-edge/leaf.js +1 -0
  20. package/tests/fixtures/cycle-fixtures/duplicate-edge/other.js +1 -0
  21. package/tests/fixtures/cycle-fixtures/duplicate-edge/util.js +2 -0
  22. package/tests/fixtures/cycle-fixtures/mixed-duplicate/base.mjs +1 -0
  23. package/tests/fixtures/cycle-fixtures/mixed-duplicate/helper.mjs +2 -0
  24. package/tests/fixtures/cycle-fixtures/mixed-duplicate/index.mjs +3 -0
  25. package/tests/fixtures/cycle-fixtures/self-import/index.js +2 -0
  26. package/tests/fixtures/cycle-fixtures/self-import/leaf.js +1 -0
  27. package/tests/fixtures/cycle-fixtures/self-import/self.js +3 -0
  28. package/tests/fixtures/cycle-fixtures/three-cycle/index.js +3 -0
  29. package/tests/fixtures/cycle-fixtures/three-cycle/leaf.js +1 -0
  30. package/tests/fixtures/cycle-fixtures/three-cycle/w.js +2 -0
  31. package/tests/fixtures/cycle-fixtures/three-cycle/x.js +2 -0
  32. package/tests/fixtures/cycle-fixtures/three-cycle/y.js +2 -0
  33. package/tests/fixtures/cycle-fixtures/three-cycle/z.js +3 -0
  34. package/tests/fixtures/cycle-fixtures/two-cycle/a.js +2 -0
  35. package/tests/fixtures/cycle-fixtures/two-cycle/b.js +2 -0
  36. package/tests/fixtures/cycle-fixtures/two-cycle/index.js +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidwells/extract-deps",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Get imports and requires from a codebase without AST",
5
5
  "main": "src/index.js",
6
6
  "exports": {
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
- return fs.statSync(filePath).isFile()
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 = fs.realpathSync(filePath)
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 content = fs.readFileSync(filePath, 'utf8')
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 = fs.realpathSync(resolvedPath)
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 targetContent = fs.readFileSync(resolvedPath, 'utf8')
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 = resolveImport(from, parentURL)
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 targetContent = fs.readFileSync(resolvedPath, 'utf8')
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
- * Get all dependency file paths in build order (dependencies first), deduplicated
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
  }
@@ -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
- const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
45
- const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
46
- const propertyExports = getExportsProperty(code, sanitizedCode)
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')
@@ -1,4 +1,4 @@
1
- const { getNames, extractExportName, stripComments, stripStringContents } = require('./utils')
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 = stripComments(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)
@@ -1,4 +1,4 @@
1
- const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, stripComments, stripStringContents } = require('./utils')
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 = stripComments(code)
45
- code = stripStringContents(code)
46
+ code = stripCommentsThenStrings(code)
46
47
  const staticImports = getStaticImports(code)
47
- const dynamicImports = getDynamicImports(code)
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)
@@ -1,4 +1,4 @@
1
- const { isBuiltIn, isRelativeImport, getNames, stripComments, stripStringContents } = require('./utils')
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 = stripComments(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()
@@ -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,2 @@
1
+ const peer = require('./peer')
2
+ module.exports = { run: () => peer }
@@ -0,0 +1 @@
1
+ module.exports = { leaf: true }
@@ -0,0 +1,3 @@
1
+ const index = require('./index')
2
+ const leaf = require('./leaf')
3
+ module.exports = { index, leaf }
@@ -0,0 +1,2 @@
1
+ const c = require('./c')
2
+ module.exports = { a: () => c }
@@ -0,0 +1,2 @@
1
+ const c = require('./c')
2
+ module.exports = { b: () => c }
@@ -0,0 +1,2 @@
1
+ const d = require('./d')
2
+ module.exports = { c: () => d }
@@ -0,0 +1 @@
1
+ module.exports = { d: true }
@@ -0,0 +1,3 @@
1
+ const a = require('./a')
2
+ const b = require('./b')
3
+ module.exports = { a, b }
@@ -0,0 +1,5 @@
1
+ const a = require('./util')
2
+ const b = require('./util.js')
3
+ const c = require('./util')
4
+ const other = require('./other')
5
+ module.exports = { a, b, c, other }
@@ -0,0 +1 @@
1
+ module.exports = { leaf: true }
@@ -0,0 +1 @@
1
+ module.exports = { other: true }
@@ -0,0 +1,2 @@
1
+ const leaf = require('./leaf')
2
+ module.exports = { util: () => leaf }
@@ -0,0 +1 @@
1
+ export const base = 1
@@ -0,0 +1,2 @@
1
+ import { base } from './base.mjs'
2
+ export const helper = () => base
@@ -0,0 +1,3 @@
1
+ import { helper } from './helper.mjs'
2
+ const again = require('./helper.mjs')
3
+ export const run = () => [helper, again]
@@ -0,0 +1,2 @@
1
+ const self = require('./self')
2
+ module.exports = { self }
@@ -0,0 +1 @@
1
+ module.exports = { leaf: true }
@@ -0,0 +1,3 @@
1
+ const me = require('./self')
2
+ const leaf = require('./leaf')
3
+ module.exports = { me, leaf }
@@ -0,0 +1,3 @@
1
+ const w = require('./w')
2
+ const x = require('./x')
3
+ module.exports = { w, x }
@@ -0,0 +1 @@
1
+ module.exports = { leaf: true }
@@ -0,0 +1,2 @@
1
+ const x = require('./x')
2
+ module.exports = { w: () => x }
@@ -0,0 +1,2 @@
1
+ const y = require('./y')
2
+ module.exports = { x: () => y }
@@ -0,0 +1,2 @@
1
+ const z = require('./z')
2
+ module.exports = { y: () => z }
@@ -0,0 +1,3 @@
1
+ const x = require('./x')
2
+ const leaf = require('./leaf')
3
+ module.exports = { z: () => [x, leaf] }
@@ -0,0 +1,2 @@
1
+ const b = require('./b')
2
+ module.exports = { ping: () => b.pong() }
@@ -0,0 +1,2 @@
1
+ const a = require('./a')
2
+ module.exports = { pong: () => a }
@@ -0,0 +1,2 @@
1
+ const a = require('./a')
2
+ module.exports = { run: () => a.ping() }