@davidwells/extract-deps 0.1.7 → 0.1.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidwells/extract-deps",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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
@@ -79,18 +79,22 @@ function verifyImportedValuesExist(dep, targetDeps, targetPath, sourcePath, erro
79
79
 
80
80
  // Check CommonJS exports
81
81
  const exportedNames = new Set()
82
+ let hasReExportAll = false
82
83
  if (targetDeps.moduleExports && targetDeps.moduleExports.length > 0) {
83
84
  for (const exp of targetDeps.moduleExports) {
84
85
  if (exp.exportType === 'named') {
85
86
  exportedNames.add(exp.exportName)
86
87
  } else if (exp.exportType === 'default' && exp.keys) {
87
88
  exp.keys.forEach(key => exportedNames.add(key))
89
+ } else if (exp.exportType === 'all') {
90
+ // Bundler-emitted dynamic re-export-all (Object.keys+forEach,
91
+ // __exportStar, __reExport). Same semantics as ESM `export * from`.
92
+ hasReExportAll = true
88
93
  }
89
94
  }
90
95
  }
91
96
 
92
97
  // Check ESM exports
93
- let hasReExportAll = false
94
98
  if (targetDeps.exports && targetDeps.exports.length > 0) {
95
99
  for (const exp of targetDeps.exports) {
96
100
  if (exp.exportType === 'all') {
@@ -41,22 +41,33 @@ function getCjsExports(code) {
41
41
  const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
42
42
  const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
43
43
  const propertyExports = getExportsProperty(code, sanitizedCode)
44
-
44
+
45
45
  // Quick pre-checks to avoid expensive regex operations when not needed
46
46
  const objectAssignExports = sanitizedCode.includes('Object.assign')
47
47
  ? getObjectAssignExports(code, sanitizedCode)
48
48
  : []
49
-
49
+
50
50
  const definePropertyExports = sanitizedCode.includes('Object.defineProperty')
51
51
  ? getDefinePropertyExports(code, sanitizedCode)
52
52
  : []
53
53
 
54
+ // Detect dynamic CJS re-export-all patterns emitted by Rollup, tsc, esbuild,
55
+ // and friends. Cheap substring guard first to avoid the regex work on files
56
+ // that obviously can't contain these patterns.
57
+ const reExportAllExports =
58
+ sanitizedCode.includes('Object.keys(') ||
59
+ sanitizedCode.includes('__exportStar') ||
60
+ sanitizedCode.includes('__reExport')
61
+ ? getReExportAllPatterns(code, sanitizedCode)
62
+ : []
63
+
54
64
  /*
55
65
  console.log('assignmentExports', assignmentExports)
56
66
  console.log('modulePropertyExports', modulePropertyExports)
57
67
  console.log('propertyExports', propertyExports)
58
68
  console.log('objectAssignExports', objectAssignExports)
59
69
  console.log('definePropertyExports', definePropertyExports)
70
+ console.log('reExportAllExports', reExportAllExports)
60
71
  /** */
61
72
 
62
73
  const allExports = []
@@ -65,6 +76,7 @@ function getCjsExports(code) {
65
76
  .concat(propertyExports)
66
77
  .concat(objectAssignExports)
67
78
  .concat(definePropertyExports)
79
+ .concat(reExportAllExports)
68
80
 
69
81
  return allExports.sort((a, b) => a.index - b.index)
70
82
  }
@@ -372,6 +384,83 @@ function getDefinePropertyExports(originalText = '', sanitizedText = originalTex
372
384
  return exportStatements
373
385
  }
374
386
 
387
+ /**
388
+ * Detect dynamic CJS re-export-all patterns where a module forwards every
389
+ * named export of another module at runtime. Bundlers emit these for ESM
390
+ * `export * from './x'` syntax when targeting CJS:
391
+ *
392
+ * - Rollup: `Object.keys(X).forEach(function(k){ if (k!=='default' && ...) exports[k]=X[k] })`
393
+ * - tsc/tslib: `__exportStar(require('./x'), exports)` / `tslib.__exportStar(...)`
394
+ * - esbuild: `__reExport(stdin_exports, require('./x'))`
395
+ *
396
+ * We don't try to resolve the require and merge names — that's recursive and
397
+ * filesystem-dependent. Instead each match emits `exportType: 'all'`, the same
398
+ * marker `get-exports.js` uses for ESM `export * from`. Downstream validators
399
+ * already skip name-verification when they see this marker, so importing a
400
+ * name that's only available via dynamic re-export no longer false-positives.
401
+ *
402
+ * Captures the require path or source-variable name into `from` when visible,
403
+ * for debugging and future resolve-and-merge work.
404
+ */
405
+ function getReExportAllPatterns(originalText = '', sanitizedText = originalText) {
406
+ const exportStatements = []
407
+
408
+ // Rollup `Object.keys(X).forEach(function(k){ ... exports[k] = X[k] ... })`.
409
+ // Allow either `function (k)` or `(k) =>` and any condition on `k` inside.
410
+ const rollupPattern = /Object\.keys\s*\(\s*(\w+)\s*\)\s*\.forEach\s*\(\s*(?:function\s*\(\s*\w+\s*\)|\(?\s*\w+\s*\)?\s*=>)\s*\{[^}]*exports\s*\[\s*\w+\s*\]\s*=\s*\1\s*\[/g
411
+ let result
412
+ while ((result = rollupPattern.exec(sanitizedText)) !== null) {
413
+ const sourceVar = result[1]
414
+ exportStatements.push({
415
+ line: result[0].split('\n')[0].trim(),
416
+ exportType: 'all',
417
+ exportName: '',
418
+ exportValue: result[0],
419
+ from: sourceVar,
420
+ index: result.index,
421
+ isExport: true,
422
+ isReExportAll: true
423
+ })
424
+ }
425
+
426
+ // tslib / tsc `__exportStar(<source>, exports)` where source is either
427
+ // a `require('./x')` call or a bare identifier holding a prior require.
428
+ const exportStarPattern = /(?:\w+\.)?__exportStar\s*\(\s*(?:require\s*\(\s*['"]([^'"]+)['"]\s*\)|(\w+))\s*,\s*(?:module\.)?exports\s*\)/g
429
+ while ((result = exportStarPattern.exec(sanitizedText)) !== null) {
430
+ const from = result[1] || result[2]
431
+ exportStatements.push({
432
+ line: result[0].trim(),
433
+ exportType: 'all',
434
+ exportName: '',
435
+ exportValue: result[0],
436
+ from,
437
+ index: result.index,
438
+ isExport: true,
439
+ isReExportAll: true
440
+ })
441
+ }
442
+
443
+ // esbuild `__reExport(<exports-target>, <source>)`. The exports-target is
444
+ // typically an identifier ending in `_exports` or `exports`, and the source
445
+ // is again a `require()` call or an identifier.
446
+ const reExportPattern = /__reExport\s*\(\s*\w+\s*,\s*(?:require\s*\(\s*['"]([^'"]+)['"]\s*\)|(\w+))\s*(?:,\s*[^)]*)?\)/g
447
+ while ((result = reExportPattern.exec(sanitizedText)) !== null) {
448
+ const from = result[1] || result[2]
449
+ exportStatements.push({
450
+ line: result[0].trim(),
451
+ exportType: 'all',
452
+ exportName: '',
453
+ exportValue: result[0],
454
+ from,
455
+ index: result.index,
456
+ isExport: true,
457
+ isReExportAll: true
458
+ })
459
+ }
460
+
461
+ return exportStatements
462
+ }
463
+
375
464
  if (require.main === module) {
376
465
  const code = `
377
466
  // module.exports assignment
@@ -1,13 +1,28 @@
1
1
  const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, stripComments } = require('./utils')
2
2
 
3
3
  // https://regex101.com/r/cfEmlm/1
4
- const IMPORT_REGEX = /import(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s](.*[@\w_-]+)["'\s]+;?$/gm
4
+ // Path excludes quotes, newlines, and `;` so multi-statement lines like
5
+ // `import a from 'a'; import b from 'b'` parse as two distinct matches
6
+ // instead of one match that spans the semicolon. The leading separator is
7
+ // `["'\s]+` (not `["'\s]`) so it can consume both the gap whitespace and the
8
+ // opening quote in one go — without that, the path group is left starting on
9
+ // the quote and fails for side-effect imports like `import './foo.js'`.
10
+ // Closing is `\s*["']` (not `["'\s]+;?`) so the captured `line` stops at the
11
+ // closing quote instead of bleeding into the next line's whitespace.
12
+ // Negative lookahead `(?!\s*(?:assert|with)\s*\{)` keeps this from matching
13
+ // import-attributes/assertion imports — those go through ASSERTION_IMPORT_REGEX
14
+ // and would otherwise be double-counted in `getImports`.
15
+ const IMPORT_REGEX = /import(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["'](?!\s*(?:assert|with)\s*\{)/gm
5
16
  // https://regex101.com/r/UDmXlb/1
6
17
  const IMPORT_TYPE_REGEX = /import\(['"](.*)['"]\)\.?([A-Za-z_0-9.]*)?/gm
7
18
  // https://regex101.com/r/UDjMoV/1
8
- const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)?await\s+import\(([^()]+(?:\([^()]*\)[^()]*)*)\)/gm
19
+ // `await` is optional so `import('./foo').then(...)`, `const p = import('./foo')`,
20
+ // `return import('./foo')`, and `lazy(() => import('./foo'))` all match.
21
+ // `(?<!\.)\b` rejects member access (`obj.import(...)`) and identifiers like
22
+ // `myimport(...)`.
23
+ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)?(?:await\s+)?(?<!\.)\bimport\(([^()]+(?:\([^()]*\)[^()]*)*)\)/gm
9
24
  // https://regex101.com/r/example/1
10
- const ASSERTION_IMPORT_REGEX = /(?:^|\n)\s*import\s+(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s](.*[@\w_-]+)["'\s]+(assert|with)\s*\{([^}]+)\};?$/gm
25
+ const ASSERTION_IMPORT_REGEX = /(?:^|\n)\s*import\s+(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["']\s+(assert|with)\s*\{([^}]+)\}/gm
11
26
 
12
27
  function getImports(code = '', options = {}) {
13
28
  // Strip comments to avoid false positives
@@ -26,7 +41,10 @@ function getStaticImports(targetText = '') {
26
41
  let importStatements = []
27
42
  var result;
28
43
  while((result = IMPORT_REGEX.exec(targetText)) !== null) {
29
- const [ line, names, from ] = result
44
+ const [ line, names, rawFrom ] = result
45
+ // Side-effect imports (e.g. `import './foo.js'`) and unnamed-default imports skip the
46
+ // optional `from` capture, so the opening quote ends up inside the path group. Strip it.
47
+ const from = rawFrom.replace(/^["'`]|["'`]$/g, '')
30
48
  // Skip TypeScript type-only imports — erased at compile time, no runtime resolution needed
31
49
  if (/^\s*import\s+type\s/.test(line)) {
32
50
  continue
@@ -104,7 +122,8 @@ function getAssertionImports(targetText = '') {
104
122
  let importStatements = []
105
123
  var result;
106
124
  while((result = ASSERTION_IMPORT_REGEX.exec(targetText)) !== null) {
107
- let [ line, names, from, keyword, assertion ] = result
125
+ let [ line, names, rawFrom, keyword, assertion ] = result
126
+ const from = rawFrom.replace(/^["'`]|["'`]$/g, '')
108
127
  const namesValues = (names || '').trim()
109
128
  // Remove leading newlines and whitespace from the line
110
129
  line = line.replace(/^\s+|\s+$/g, '')
@@ -759,4 +759,96 @@ Object.defineProperty(exports, 'version', {
759
759
  }
760
760
  })
761
761
 
762
+ test('detects Rollup-style Object.keys(X).forEach re-export-all', () => {
763
+ // Matches the pattern emitted by Rollup (and used in AWS SDK v3 dist-cjs)
764
+ // for ESM `export * from './x'` when targeting CJS.
765
+ const code = `
766
+ var errors = require('./models/errors');
767
+ Object.keys(errors).forEach(function (k) {
768
+ if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) exports[k] = errors[k];
769
+ });
770
+ exports.realNamed = realNamed;
771
+ `
772
+
773
+ const found = getCjsExports(code)
774
+ const reExports = found.filter(e => e.exportType === 'all')
775
+ assert.equal(reExports.length, 1, 'Should detect 1 dynamic re-export-all')
776
+ assert.equal(reExports[0].from, 'errors', 'Should capture the source variable name')
777
+ assert.is(reExports[0].isReExportAll, true, 'Should set isReExportAll flag')
778
+
779
+ // The explicit named export must still be picked up alongside
780
+ assert.ok(found.some(e => e.exportType === 'named' && e.exportName === 'realNamed'))
781
+ })
782
+
783
+ test('detects tsc/tslib __exportStar re-export-all', () => {
784
+ const code = `
785
+ var tslib_1 = require("tslib");
786
+ __exportStar(require("./module-a"), exports);
787
+ tslib_1.__exportStar(require("./module-b"), exports);
788
+ var moduleC = require("./module-c");
789
+ __exportStar(moduleC, exports);
790
+ `
791
+
792
+ const found = getCjsExports(code)
793
+ const reExports = found.filter(e => e.exportType === 'all')
794
+ assert.equal(reExports.length, 3, 'Should detect 3 __exportStar calls')
795
+
796
+ const fromValues = reExports.map(e => e.from).sort()
797
+ assert.equal(fromValues, ['./module-a', './module-b', 'moduleC'].sort())
798
+ })
799
+
800
+ test('detects esbuild __reExport re-export-all', () => {
801
+ const code = `
802
+ var stdin_exports = {};
803
+ __export(stdin_exports, { foo: () => foo });
804
+ __reExport(stdin_exports, require("./module-a"));
805
+ var moduleB = require("./module-b");
806
+ __reExport(stdin_exports, moduleB);
807
+ `
808
+
809
+ const found = getCjsExports(code)
810
+ const reExports = found.filter(e => e.exportType === 'all')
811
+ assert.equal(reExports.length, 2, 'Should detect 2 __reExport calls')
812
+
813
+ const fromValues = reExports.map(e => e.from).sort()
814
+ assert.equal(fromValues, ['./module-a', 'moduleB'].sort())
815
+ })
816
+
817
+ test('re-export-all patterns coexist with explicit named exports', () => {
818
+ // A real-world file mixes both — make sure both end up in moduleExports.
819
+ const code = `
820
+ var errors = require('./errors');
821
+ exports.Foo = Foo;
822
+ exports.Bar = Bar;
823
+ Object.keys(errors).forEach(function (k) {
824
+ if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) exports[k] = errors[k];
825
+ });
826
+ exports.Baz = Baz;
827
+ `
828
+
829
+ const found = getCjsExports(code)
830
+ const named = found.filter(e => e.exportType === 'named').map(e => e.exportName).sort()
831
+ assert.equal(named, ['Bar', 'Baz', 'Foo'])
832
+
833
+ const reExports = found.filter(e => e.exportType === 'all')
834
+ assert.equal(reExports.length, 1)
835
+ assert.equal(reExports[0].from, 'errors')
836
+ })
837
+
838
+ test('does NOT flag unrelated Object.keys / forEach calls as re-export-all', () => {
839
+ // Only the specific `exports[k] = X[k]` shape should trigger detection.
840
+ const code = `
841
+ Object.keys(someObj).forEach(function (k) {
842
+ console.log(k, someObj[k]);
843
+ });
844
+ Object.keys(config).forEach((k) => {
845
+ process.env[k] = config[k];
846
+ });
847
+ `
848
+
849
+ const found = getCjsExports(code)
850
+ const reExports = found.filter(e => e.exportType === 'all')
851
+ assert.equal(reExports.length, 0, 'Should not match Object.keys patterns that do not assign to exports')
852
+ })
853
+
762
854
  test.run()
@@ -536,4 +536,143 @@ import foo from 'foo' // http://example.com
536
536
  assert.equal(importsFound[0].from, 'foo', 'Should find foo import')
537
537
  })
538
538
 
539
+ test('side-effect imports strip surrounding quotes from `from`', () => {
540
+ // Regression: before the fix, the static-import regex captured the opening quote
541
+ // inside the path group for side-effect imports (no `from` keyword), producing
542
+ // values like `'./foo.js` which broke isRelative / isBuiltIn classification.
543
+ const code = `
544
+ import './message/email/gmail/index.js'
545
+ import "./message/in-app/re-notifications/index.js"
546
+ import 'side-effect-package'
547
+ import "node:fs"
548
+ `
549
+
550
+ const importsFound = normalizeTestResult(getStaticImports(code))
551
+
552
+ assert.equal(importsFound.length, 4, 'Should find 4 side-effect imports')
553
+
554
+ assert.equal(importsFound[0].from, './message/email/gmail/index.js')
555
+ assert.is(importsFound[0].isRelative, true, 'single-quoted relative path is relative')
556
+ assert.is(importsFound[0].isBuiltIn, false)
557
+
558
+ assert.equal(importsFound[1].from, './message/in-app/re-notifications/index.js')
559
+ assert.is(importsFound[1].isRelative, true, 'double-quoted relative path is relative')
560
+
561
+ assert.equal(importsFound[2].from, 'side-effect-package')
562
+ assert.is(importsFound[2].isRelative, false)
563
+ assert.is(importsFound[2].isBuiltIn, false)
564
+
565
+ assert.equal(importsFound[3].from, 'node:fs')
566
+ assert.is(importsFound[3].isBuiltIn, true, 'node: builtins detected after quote strip')
567
+ })
568
+
569
+ test('side-effect imports preserve definitions/varName semantics', () => {
570
+ const code = `import './foo.js'`
571
+ const [imp] = getStaticImports(code)
572
+
573
+ assert.equal(imp.from, './foo.js')
574
+ assert.is(imp.isRelative, true)
575
+ assert.is(imp.isImport, true)
576
+ assert.equal(imp.varName, '', 'side-effect imports have no bound names')
577
+ assert.is(imp.definitions, null, 'side-effect imports have null definitions')
578
+ })
579
+
580
+ test('multiple static imports on a single line', () => {
581
+ // Regression: the `(.*[@\w_-]+)["'\s]+;?$` pattern was greedy plus EOL-anchored,
582
+ // so a line like `import a from 'a'; import b from 'b';` matched as ONE import
583
+ // whose path was `a'; import b from 'b`. Tightening the path char-class and
584
+ // dropping the `$` anchor turns this into separate matches.
585
+ const code = `import a from 'a'; import b from 'b'; import c from 'c';`
586
+ const importsFound = normalizeTestResult(getStaticImports(code))
587
+
588
+ assert.equal(importsFound.length, 3, 'Should find 3 imports on one line')
589
+ assert.equal(importsFound[0].from, 'a')
590
+ assert.equal(importsFound[1].from, 'b')
591
+ assert.equal(importsFound[2].from, 'c')
592
+
593
+ // Verify names parsed independently too
594
+ assert.equal(importsFound[0].varName, 'a')
595
+ assert.equal(importsFound[1].varName, 'b')
596
+ assert.equal(importsFound[2].varName, 'c')
597
+ })
598
+
599
+ test('static + assertion imports on the same line do not double-count', () => {
600
+ // The static IMPORT_REGEX uses a negative lookahead for `assert {` / `with {`
601
+ // so assertion imports flow only through ASSERTION_IMPORT_REGEX instead of
602
+ // being matched by both.
603
+ const code = `
604
+ import foo from 'fs'
605
+ import data from './data.json' assert { type: 'json' }
606
+ import styles from './styles.css' with { type: 'css' }
607
+ `
608
+
609
+ const all = normalizeTestResult(getImports(code, { includeAssertionImports: true }))
610
+ assert.equal(all.length, 3, 'Should not double-count assertion imports')
611
+
612
+ // Static-only call must also skip the assertion lines (no partial path matches)
613
+ const staticOnly = normalizeTestResult(getStaticImports(code))
614
+ assert.equal(staticOnly.length, 1, 'Static regex should skip assertion-import lines')
615
+ assert.equal(staticOnly[0].from, 'fs')
616
+ })
617
+
618
+ test('dynamic imports without await are detected', () => {
619
+ // Regression: DYNAMIC_IMPORT_REGEX required `await\s+import(`, missing
620
+ // every lazy-load pattern. `await` is now optional.
621
+ const code = `
622
+ import('./then-chain.js').then(m => m)
623
+ const eager = import('./eager.js')
624
+ return import('./return.js')
625
+ const Comp = lazy(() => import('./Component.js'))
626
+ const [a, b] = await Promise.all([import('./a.js'), import('./b.js')])
627
+ `
628
+
629
+ const importsFound = normalizeTestResult(getDynamicImports(code))
630
+ const froms = importsFound.map(i => i.from).sort()
631
+
632
+ assert.equal(importsFound.length, 6, 'Should find 6 dynamic imports')
633
+ assert.equal(froms, [
634
+ './Component.js',
635
+ './a.js',
636
+ './b.js',
637
+ './eager.js',
638
+ './return.js',
639
+ './then-chain.js',
640
+ ].sort())
641
+
642
+ // All flagged as dynamic
643
+ assert.ok(importsFound.every(i => i.isDynamic), 'All should have isDynamic: true')
644
+ })
645
+
646
+ test('dynamic-import regex rejects member access and identifier prefixes', () => {
647
+ // `obj.import('x')` and `myimport('x')` are not dynamic imports — the
648
+ // `(?<!\.)\b` guard prevents these from matching now that `await` is optional.
649
+ const code = `
650
+ obj.import('./not-an-import.js')
651
+ myimport('./also-not.js')
652
+ const u = import.meta.url
653
+ const real = import('./real.js')
654
+ `
655
+
656
+ const importsFound = getDynamicImports(code)
657
+ assert.equal(importsFound.length, 1, 'Should only match the real dynamic import')
658
+ assert.equal(importsFound[0].from, './real.js')
659
+ })
660
+
661
+ test('assertion imports strip surrounding quotes from `from`', () => {
662
+ // Same class of bug as static imports — the assertion regex captured the
663
+ // opening quote into the path group when the assertion was wrapped tightly.
664
+ const code = `
665
+ import data from './data.json' assert { type: 'json' }
666
+ import sheet from "./sheet.css" with { type: 'css' }
667
+ `
668
+
669
+ const importsFound = normalizeTestResult(getAssertionImports(code))
670
+
671
+ assert.equal(importsFound.length, 2)
672
+ assert.equal(importsFound[0].from, './data.json')
673
+ assert.is(importsFound[0].isRelative, true)
674
+ assert.equal(importsFound[1].from, './sheet.css')
675
+ assert.is(importsFound[1].isRelative, true)
676
+ })
677
+
539
678
  test.run()