@davidwells/extract-deps 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/dep-graph.js +152 -37
  3. package/src/get-cjs-exports.js +7 -0
  4. package/src/get-exports.js +2 -3
  5. package/src/get-imports.js +5 -4
  6. package/src/get-requires.js +2 -3
  7. package/src/index.js +3 -1
  8. package/src/utils.js +21 -0
  9. package/tests/dep-graph-cycles.test.js +111 -0
  10. package/tests/dep-graph-json.test.js +79 -0
  11. package/tests/fixtures/cycle-fixtures/cycle-entry/index.js +2 -0
  12. package/tests/fixtures/cycle-fixtures/cycle-entry/leaf.js +1 -0
  13. package/tests/fixtures/cycle-fixtures/cycle-entry/peer.js +3 -0
  14. package/tests/fixtures/cycle-fixtures/diamond/a.js +2 -0
  15. package/tests/fixtures/cycle-fixtures/diamond/b.js +2 -0
  16. package/tests/fixtures/cycle-fixtures/diamond/c.js +2 -0
  17. package/tests/fixtures/cycle-fixtures/diamond/d.js +1 -0
  18. package/tests/fixtures/cycle-fixtures/diamond/index.js +3 -0
  19. package/tests/fixtures/cycle-fixtures/duplicate-edge/index.js +5 -0
  20. package/tests/fixtures/cycle-fixtures/duplicate-edge/leaf.js +1 -0
  21. package/tests/fixtures/cycle-fixtures/duplicate-edge/other.js +1 -0
  22. package/tests/fixtures/cycle-fixtures/duplicate-edge/util.js +2 -0
  23. package/tests/fixtures/cycle-fixtures/mixed-duplicate/base.mjs +1 -0
  24. package/tests/fixtures/cycle-fixtures/mixed-duplicate/helper.mjs +2 -0
  25. package/tests/fixtures/cycle-fixtures/mixed-duplicate/index.mjs +3 -0
  26. package/tests/fixtures/cycle-fixtures/self-import/index.js +2 -0
  27. package/tests/fixtures/cycle-fixtures/self-import/leaf.js +1 -0
  28. package/tests/fixtures/cycle-fixtures/self-import/self.js +3 -0
  29. package/tests/fixtures/cycle-fixtures/three-cycle/index.js +3 -0
  30. package/tests/fixtures/cycle-fixtures/three-cycle/leaf.js +1 -0
  31. package/tests/fixtures/cycle-fixtures/three-cycle/w.js +2 -0
  32. package/tests/fixtures/cycle-fixtures/three-cycle/x.js +2 -0
  33. package/tests/fixtures/cycle-fixtures/three-cycle/y.js +2 -0
  34. package/tests/fixtures/cycle-fixtures/three-cycle/z.js +3 -0
  35. package/tests/fixtures/cycle-fixtures/two-cycle/a.js +2 -0
  36. package/tests/fixtures/cycle-fixtures/two-cycle/b.js +2 -0
  37. package/tests/fixtures/cycle-fixtures/two-cycle/index.js +2 -0
  38. package/types/dep-graph.d.ts +4 -1
  39. package/types/dep-graph.d.ts.map +1 -1
  40. package/types/get-cjs-exports.d.ts.map +1 -1
  41. package/types/get-exports.d.ts.map +1 -1
  42. package/types/get-imports.d.ts.map +1 -1
  43. package/types/get-requires.d.ts.map +1 -1
  44. package/types/utils.d.ts +8 -0
  45. package/types/utils.d.ts.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidwells/extract-deps",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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
@@ -90,7 +90,10 @@ function fsExistsSync(path) {
90
90
 
91
91
  function isFileSync(filePath) {
92
92
  try {
93
- 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
94
97
  } catch (error) {
95
98
  return false
96
99
  }
@@ -161,6 +164,10 @@ function resolveImportPathUncached(baseDir, importPath) {
161
164
  * @param {boolean} throwOnFirstError - Whether to throw immediately on first error
162
165
  */
163
166
  function verifyImportedValuesExist(dep, targetDeps, targetPath, sourcePath, errors, throwOnFirstError) {
167
+ // JSON modules have no export statements to check: the parsed value IS the default export
168
+ // (ESM, `with { type: 'json' }`) or module.exports (CJS require). Scanning the JSON text
169
+ // for exports found none and reported a missing default export for `require('./x.json')`.
170
+ if (/\.json$/i.test(targetPath)) return
164
171
  // Check if this is a destructured import
165
172
  if (dep.definitions && dep.definitions.expectsDestructuring && dep.definitions.destructuredParameters) {
166
173
  const importedNames = dep.definitions.destructuredParameters.map(p => p.name.replace(/,$/, ''))
@@ -448,7 +455,149 @@ function depGraph(filePath, options = {}) {
448
455
 
449
456
 
450
457
  /**
451
- * Get all dependency file paths in build order (dependencies first), deduplicated
458
+ * Order every file in the graph so dependencies come before dependents, each file once.
459
+ *
460
+ * Kahn's algorithm over the strongly connected components (Tarjan) of the file graph:
461
+ * an import cycle (or self-import) is one component, emitted as a unit with its members
462
+ * in discovery order. Duplicate edges (one file importing a module twice) count once.
463
+ * For an acyclic graph every component is a single file, and the queue seeding and
464
+ * dependent visiting orders match a plain per-file Kahn's sort over the graph's
465
+ * insertion order. O(V + E).
466
+ *
467
+ * @param {Map<string, string[]>} graph - file -> deps (file paths and package names)
468
+ * @returns {string[]}
469
+ */
470
+ function buildOrder(graph) {
471
+ const nodes = Array.from(graph.keys())
472
+ const n = nodes.length
473
+ /** @type {Map<string, number>} */
474
+ const indexOf = new Map()
475
+ for (let i = 0; i < n; i++) indexOf.set(nodes[i], i)
476
+
477
+ // Unique file deps per node (first-occurrence order) and reverse edges. Dependents
478
+ // are appended while walking nodes in graph order, so each reverse list is in graph order.
479
+ /** @type {number[][]} */
480
+ const deps = new Array(n)
481
+ /** @type {number[][]} */
482
+ const dependents = new Array(n)
483
+ for (let i = 0; i < n; i++) dependents[i] = []
484
+ const seenStamp = new Int32Array(n).fill(-1)
485
+ for (let i = 0; i < n; i++) {
486
+ const out = []
487
+ for (const dep of graph.get(nodes[i])) {
488
+ const j = indexOf.get(dep) // package names (and unvisited paths) are not nodes
489
+ if (j === undefined || seenStamp[j] === i) continue
490
+ seenStamp[j] = i
491
+ out.push(j)
492
+ dependents[j].push(i)
493
+ }
494
+ deps[i] = out
495
+ }
496
+
497
+ // Tarjan's SCC, iterative (deep import chains must not overflow the call stack)
498
+ const UNVISITED = -1
499
+ const order = new Int32Array(n).fill(UNVISITED)
500
+ const low = new Int32Array(n)
501
+ const onStack = new Uint8Array(n)
502
+ const comp = new Int32Array(n)
503
+ const sccStack = []
504
+ const callStack = [] // [node, next dep position]
505
+ let counter = 0
506
+ let compCount = 0
507
+ for (let root = 0; root < n; root++) {
508
+ if (order[root] !== UNVISITED) continue
509
+ callStack.push(root, 0)
510
+ order[root] = low[root] = counter++
511
+ sccStack.push(root)
512
+ onStack[root] = 1
513
+ while (callStack.length > 0) {
514
+ const top = callStack.length - 2
515
+ const v = callStack[top]
516
+ const edges = deps[v]
517
+ const pos = callStack[top + 1]
518
+ if (pos < edges.length) {
519
+ callStack[top + 1] = pos + 1
520
+ const w = edges[pos]
521
+ if (order[w] === UNVISITED) {
522
+ order[w] = low[w] = counter++
523
+ sccStack.push(w)
524
+ onStack[w] = 1
525
+ callStack.push(w, 0)
526
+ } else if (onStack[w] && order[w] < low[v]) {
527
+ low[v] = order[w]
528
+ }
529
+ continue
530
+ }
531
+ callStack.length = top
532
+ if (top > 0) {
533
+ const parent = callStack[top - 2]
534
+ if (low[v] < low[parent]) low[parent] = low[v]
535
+ }
536
+ if (low[v] === order[v]) {
537
+ let w
538
+ do {
539
+ w = sccStack.pop()
540
+ onStack[w] = 0
541
+ comp[w] = compCount
542
+ } while (w !== v)
543
+ compCount++
544
+ }
545
+ }
546
+ }
547
+
548
+ // Members per component in graph (discovery) order
549
+ /** @type {number[][]} */
550
+ const members = new Array(compCount)
551
+ for (let c = 0; c < compCount; c++) members[c] = []
552
+ for (let i = 0; i < n; i++) members[comp[i]].push(i)
553
+
554
+ // Component in-degree: distinct other components it depends on
555
+ const compInDegree = new Int32Array(compCount)
556
+ const compStamp = new Int32Array(compCount).fill(-1)
557
+ for (let c = 0; c < compCount; c++) {
558
+ for (const v of members[c]) {
559
+ for (const w of deps[v]) {
560
+ const cw = comp[w]
561
+ if (cw === c || compStamp[cw] === c) continue
562
+ compStamp[cw] = c
563
+ compInDegree[c]++
564
+ }
565
+ }
566
+ }
567
+
568
+ // Kahn's over components, seeded in graph order
569
+ const queue = []
570
+ const queued = new Uint8Array(compCount)
571
+ for (let i = 0; i < n; i++) {
572
+ const c = comp[i]
573
+ if (compInDegree[c] === 0 && !queued[c]) {
574
+ queued[c] = 1
575
+ queue.push(c)
576
+ }
577
+ }
578
+ const result = []
579
+ compStamp.fill(-1)
580
+ for (let head = 0; head < queue.length; head++) {
581
+ const c = queue[head]
582
+ for (const v of members[c]) result.push(nodes[v])
583
+ for (const v of members[c]) {
584
+ for (const d of dependents[v]) {
585
+ const cd = comp[d]
586
+ if (cd === c || compStamp[cd] === c) continue
587
+ compStamp[cd] = c
588
+ if (--compInDegree[cd] === 0) {
589
+ queued[cd] = 1
590
+ queue.push(cd)
591
+ }
592
+ }
593
+ }
594
+ }
595
+ return result
596
+ }
597
+
598
+ /**
599
+ * Get all dependency file paths in build order (dependencies first), deduplicated.
600
+ * Files in an import cycle are kept together, in discovery order.
452
601
  * @param {string} filePath - Entry file path
453
602
  * @param {object} options - Options to pass to depGraph
454
603
  * @param {boolean} [options.verifyGraph] - If true, verify imports/exports. Default: true
@@ -492,42 +641,8 @@ function resolveDepPaths(filePath, options = {}) {
492
641
  }
493
642
  }
494
643
 
495
- // Calculate in-degree (number of file dependencies) for each file
496
- const inDegree = new Map()
497
- for (const [file, deps] of graph) {
498
- // Only count file deps, not node_modules
499
- const fileDeps = deps.filter(d => d.startsWith('/'))
500
- inDegree.set(file, fileDeps.length)
501
- }
502
-
503
- // Queue of files with no dependencies (can be processed first)
504
- const queue = []
505
- for (const [file, degree] of inDegree) {
506
- if (degree === 0) {
507
- queue.push(file)
508
- }
509
- }
510
-
511
- const buildOrder = []
512
-
513
- while (queue.length > 0) {
514
- const file = queue.shift()
515
- buildOrder.push(file)
516
-
517
- // Find all files that depend on this file and decrement their in-degree
518
- for (const [dependent, deps] of graph) {
519
- if (deps.includes(file)) {
520
- const newDegree = inDegree.get(dependent) - 1
521
- inDegree.set(dependent, newDegree)
522
- if (newDegree === 0) {
523
- queue.push(dependent)
524
- }
525
- }
526
- }
527
- }
528
-
529
644
  const result = {
530
- files: buildOrder,
645
+ files: buildOrder(graph),
531
646
  nodeModules: Array.from(nodeModulesSet),
532
647
  builtIns: Array.from(builtInsSet)
533
648
  }
@@ -34,7 +34,14 @@ 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
@@ -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
@@ -22,8 +22,7 @@ function getExports(code = '', options = {}) {
22
22
  // alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
23
23
  // with a later quote and shift subsequent backtick pairing, which could
24
24
  // swallow real `export` declarations a few lines later.
25
- code = stripComments(code)
26
- code = stripStringContents(code)
25
+ code = stripCommentsThenStrings(code)
27
26
  const defaultExports = getDefaultExports(code)
28
27
  const namedExports = getNamedExports(code)
29
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
@@ -43,10 +43,11 @@ function getImports(code = '', options = {}) {
43
43
  // removed before `stripStringContents` looks for string literals.
44
44
  // (Running the two in the opposite order let comment chars masquerade as
45
45
  // string openers and swallow real `import`/`export` lines further down.)
46
- code = stripComments(code)
47
- code = stripStringContents(code)
46
+ code = stripCommentsThenStrings(code)
48
47
  const staticImports = getStaticImports(code)
49
- 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) : []
50
51
  if (options.includeAssertionImports) {
51
52
  const assertionImports = getAssertionImports(code)
52
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
@@ -111,8 +111,7 @@ function getRequires(code) {
111
111
  // alone would mis-eat `//` inside strings; running it first instead means
112
112
  // comment chars (incl. apostrophes/backticks inside JSDoc) disappear
113
113
  // before `stripStringContents` looks for real string literals.
114
- code = stripComments(code)
115
- code = stripStringContents(code)
114
+ code = stripCommentsThenStrings(code)
116
115
  const staticRequires = getStaticRequires(code)
117
116
  const dynamicRequires = getDynamicRequires(code)
118
117
  return staticRequires.concat(dynamicRequires).sort((a, b) => a.index - b.index)
package/src/index.js CHANGED
@@ -45,7 +45,9 @@ function extractDeps(code, filePath = '') {
45
45
  }
46
46
 
47
47
  function parseDeps(code) {
48
- const imports = getImports(code)
48
+ // Import-attribute imports (`import data from './x.json' with { type: 'json' }`) are
49
+ // dependencies too: without them a changed JSON file never reaches its importers
50
+ const imports = getImports(code, { includeAssertionImports: true })
49
51
  //console.log('imports', imports)
50
52
  const requires = getRequires(code)
51
53
  // console.log('requires', requires)
package/src/utils.js CHANGED
@@ -291,6 +291,26 @@ function stripComments(code) {
291
291
  return stripCommentsImpl(code)
292
292
  }
293
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
+
294
314
  /**
295
315
  * Check if a module specifier is a Node.js built-in
296
316
  * @param {string} moduleName - The module specifier (e.g., 'fs', 'node:fs/promises')
@@ -504,6 +524,7 @@ module.exports = {
504
524
  stripComments,
505
525
  stripStringContents,
506
526
  stripCommentsAndStrings,
527
+ stripCommentsThenStrings,
507
528
  isBuiltIn,
508
529
  isRelativeImport,
509
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()
@@ -0,0 +1,79 @@
1
+ // JSON modules in the dependency graph (smart-ci sci-nrnc.4 / sci-nrnc.11)
2
+ const { test } = require('uvu')
3
+ const assert = require('uvu/assert')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const { resolveDepPaths, clearDepGraphCache } = require('../src/dep-graph')
8
+
9
+ function makeFiles(files) {
10
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'extract-deps-json-')))
11
+ for (const [rel, content] of Object.entries(files)) {
12
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true })
13
+ fs.writeFileSync(path.join(dir, rel), content)
14
+ }
15
+ return dir
16
+ }
17
+
18
+ const VERIFY = { verifyGraph: true, verifyNodeModules: true, includeNodeModules: true, returnErrors: true }
19
+
20
+ test('CommonJS require of JSON: listed, and no "default export" error', () => {
21
+ clearDepGraphCache()
22
+ const dir = makeFiles({
23
+ 'handler.js': "const messages = require('./messages.json')\nmodule.exports.handler = async () => messages.hello\n",
24
+ 'messages.json': '{ "hello": "Foobar!!!" }\n',
25
+ })
26
+ try {
27
+ const { files, errors } = resolveDepPaths(path.join(dir, 'handler.js'), VERIFY)
28
+ assert.ok(files.includes(path.join(dir, 'messages.json')), 'messages.json is a dependency')
29
+ assert.equal((errors || []).map(e => e.message), [])
30
+ } finally {
31
+ fs.rmSync(dir, { recursive: true, force: true })
32
+ }
33
+ })
34
+
35
+ test('CommonJS destructuring require of JSON: no export errors', () => {
36
+ clearDepGraphCache()
37
+ const dir = makeFiles({
38
+ 'handler.js': "const { hello } = require('./messages.json')\nmodule.exports.handler = async () => hello\n",
39
+ 'messages.json': '{ "hello": "hi" }\n',
40
+ })
41
+ try {
42
+ const { errors } = resolveDepPaths(path.join(dir, 'handler.js'), VERIFY)
43
+ assert.equal((errors || []).map(e => e.message), [])
44
+ } finally {
45
+ fs.rmSync(dir, { recursive: true, force: true })
46
+ }
47
+ })
48
+
49
+ test('ESM import of JSON with import attributes (with / assert): listed as a dependency', () => {
50
+ clearDepGraphCache()
51
+ const dir = makeFiles({
52
+ 'src/app.mjs': "import contract from '../docs/api.json' with { type: 'json' }\nimport legacy from './legacy.json' assert { type: 'json' }\nexport const h = () => [contract, legacy]\n",
53
+ 'src/legacy.json': '{}\n',
54
+ 'docs/api.json': '{ "version": 1 }\n',
55
+ })
56
+ try {
57
+ const { files, errors } = resolveDepPaths(path.join(dir, 'src/app.mjs'), VERIFY)
58
+ assert.ok(files.includes(path.join(dir, 'docs/api.json')), 'with { type: json } import is a dependency')
59
+ assert.ok(files.includes(path.join(dir, 'src/legacy.json')), 'assert { type: json } import is a dependency')
60
+ assert.equal((errors || []).map(e => e.message), [])
61
+ } finally {
62
+ fs.rmSync(dir, { recursive: true, force: true })
63
+ }
64
+ })
65
+
66
+ test('a missing JSON import is still reported', () => {
67
+ clearDepGraphCache()
68
+ const dir = makeFiles({
69
+ 'app.mjs': "import contract from './missing.json' with { type: 'json' }\nexport const h = () => contract\n",
70
+ })
71
+ try {
72
+ const { errors } = resolveDepPaths(path.join(dir, 'app.mjs'), VERIFY)
73
+ assert.ok((errors || []).length > 0, 'missing.json is an error')
74
+ } finally {
75
+ fs.rmSync(dir, { recursive: true, force: true })
76
+ }
77
+ })
78
+
79
+ 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() }
@@ -22,7 +22,8 @@ export function depGraph(filePath: string, options?: {
22
22
  errors: Error[];
23
23
  } | undefined;
24
24
  /**
25
- * Get all dependency file paths in build order (dependencies first), deduplicated
25
+ * Get all dependency file paths in build order (dependencies first), deduplicated.
26
+ * Files in an import cycle are kept together, in discovery order.
26
27
  * @param {string} filePath - Entry file path
27
28
  * @param {object} options - Options to pass to depGraph
28
29
  * @param {boolean} [options.verifyGraph] - If true, verify imports/exports. Default: true
@@ -42,4 +43,6 @@ export function resolveDepPaths(filePath: string, options?: {
42
43
  builtIns: string[];
43
44
  errors?: Error[];
44
45
  };
46
+ /** Drop memoized parses and resolutions (e.g. between runs in a long-lived process). */
47
+ export function clearDepGraphCache(): void;
45
48
  //# sourceMappingURL=dep-graph.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dep-graph.d.ts","sourceRoot":"","sources":["../src/dep-graph.js"],"names":[],"mappings":"AAmUA;;;;;;;;;;;GAWG;AACH,mCAVW,MAAM,YAEd;IAA0B,UAAU,GAA5B,OAAO;IACW,WAAW,GAA7B,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,kBAAkB,GAApC,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,YAAY,GAA9B,OAAO;CACf,GAAU,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAAC;IAAC,KAAK,gBAAM;IAAC,MAAM,EAAE,KAAK,EAAE,CAAA;CAAC,GAAC,SAAS,CA8BzE;AAGD;;;;;;;;;GASG;AACH,0CARW,MAAM,YAEd;IAA0B,WAAW,GAA7B,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,YAAY,GAA9B,OAAO;CACf,GAAU;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;CAAE,CAkF5F"}
1
+ {"version":3,"file":"dep-graph.d.ts","sourceRoot":"","sources":["../src/dep-graph.js"],"names":[],"mappings":"AA6ZA;;;;;;;;;;;GAWG;AACH,mCAVW,MAAM,YAEd;IAA0B,UAAU,GAA5B,OAAO;IACW,WAAW,GAA7B,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,kBAAkB,GAApC,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,YAAY,GAA9B,OAAO;CACf,GAAU,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAAC;IAAC,KAAK,gBAAM;IAAC,MAAM,EAAE,KAAK,EAAE,CAAA;CAAC,GAAC,SAAS,CA8BzE;AAgJD;;;;;;;;;;GAUG;AACH,0CARW,MAAM,YAEd;IAA0B,WAAW,GAA7B,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,iBAAiB,GAAnC,OAAO;IACW,YAAY,GAA9B,OAAO;CACf,GAAU;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAA;CAAE,CAgD5F;AArkBD,wFAAwF;AACxF,2CAKC"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-cjs-exports.d.ts","sourceRoot":"","sources":["../src/get-cjs-exports.js"],"names":[],"mappings":"AAoCA,gDAgDC;AA+BD;;;;;;;;IAqGC;AAED;;;;;;;IAqBC;AAED;;;;;;;IAqBC;AAED;;;;;;;IAiDC;AAED;;;;;;;;;IAwEC"}
1
+ {"version":3,"file":"get-cjs-exports.d.ts","sourceRoot":"","sources":["../src/get-cjs-exports.js"],"names":[],"mappings":"AAyCA,gDAoDC;AA+BD;;;;;;;;IAqGC;AAED;;;;;;;IAqBC;AAED;;;;;;;IAqBC;AAED;;;;;;;IAiDC;AAED;;;;;;;;;IAwEC"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-exports.d.ts","sourceRoot":"","sources":["../src/get-exports.js"],"names":[],"mappings":"AAiBA,+DAqCC;AAED;;;;;;;;IAqDC;AAED;;;;;;IAeC;AAED,uEAuBC;AAED;;;;;;IAeC;AAED;;;;;;;IAgBC;AAED;;;;;;IAeC;AAED;;;;;;;;;;;;IA0BC"}
1
+ {"version":3,"file":"get-exports.d.ts","sourceRoot":"","sources":["../src/get-exports.js"],"names":[],"mappings":"AAiBA,+DAsCC;AAED;;;;;;;;IAqDC;AAED;;;;;;IAeC;AAED,uEAuBC;AAED;;;;;;IAeC;AAED;;;;;;;IAgBC;AAED;;;;;;IAeC;AAED;;;;;;;;;;;;IA0BC"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-imports.d.ts","sourceRoot":"","sources":["../src/get-imports.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;IAcC;AAGD;;;;;;;;;;IAsCC;AAED;;;;;;;;;IAkBC;AAED;;;;;;;;;;;IAuBC;AAED;;;;;;;;;;;;;IA0BC"}
1
+ {"version":3,"file":"get-imports.d.ts","sourceRoot":"","sources":["../src/get-imports.js"],"names":[],"mappings":"AAsCA;;;;;;;;;;IAiBC;AAGD;;;;;;;;;;IAsCC;AAED;;;;;;;;;IAkBC;AAED;;;;;;;;;;;IAuBC;AAED;;;;;;;;;;;;;IA0BC"}
@@ -1 +1 @@
1
- {"version":3,"file":"get-requires.d.ts","sourceRoot":"","sources":["../src/get-requires.js"],"names":[],"mappings":"AA0GA;;;;;;;;;;;IAUC;AArCD;;;;;;;;;;;IAyBC;AA/FD;;;;;;;;;;;IA6BC"}
1
+ {"version":3,"file":"get-requires.d.ts","sourceRoot":"","sources":["../src/get-requires.js"],"names":[],"mappings":"AA0GA;;;;;;;;;;;IAWC;AAtCD;;;;;;;;;;;IAyBC;AA/FD;;;;;;;;;;;IA6BC"}
package/types/utils.d.ts CHANGED
@@ -63,6 +63,14 @@ export function stripStringContents(code: string): string;
63
63
  * @returns {string}
64
64
  */
65
65
  export function stripCommentsAndStrings(code: string): string;
66
+ /**
67
+ * `stripStringContents(stripComments(code))`, the prelude of the import, require
68
+ * and export scanners (comments first, then strings: see getImports). Pure in
69
+ * `code`; the latest result is kept for the next scanner of the same text.
70
+ * @param {string} code
71
+ * @returns {string}
72
+ */
73
+ export function stripCommentsThenStrings(code: string): string;
66
74
  /**
67
75
  * Check if a module specifier is a Node.js built-in
68
76
  * @param {string} moduleName - The module specifier (e.g., 'fs', 'node:fs/promises')
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AAsQA;;;;;;GAMG;AACH,oCAHW,MAAM,GACJ,MAAM,CAIlB;AAtQD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,0CAHW,MAAM,GACJ,MAAM,CA6ClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,8CAHW,MAAM,GACJ,MAAM,CAIlB;AAkKD;;;;GAIG;AACH,sCAHW,MAAM,GACJ,OAAO,CAUnB;AAED,+DAEC;AAED,yEAKC;AAgFD,6DA+DC;AAGD,yDAqCC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.js"],"names":[],"mappings":"AA0RA;;;;;;GAMG;AACH,oCAHW,MAAM,GACJ,MAAM,CAIlB;AA1RD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,0CAHW,MAAM,GACJ,MAAM,CAuDlB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,8CAHW,MAAM,GACJ,MAAM,CAIlB;AAiLD;;;;;;GAMG;AACH,+CAHW,MAAM,GACJ,MAAM,CAQlB;AAED;;;;GAIG;AACH,sCAHW,MAAM,GACJ,OAAO,CAUnB;AAED,+DAEC;AAED,yEAKC;AAgFD,6DA+DC;AAGD,yDAqCC"}