@davidwells/extract-deps 0.1.7 → 0.1.9

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.9",
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') {
@@ -136,6 +140,15 @@ function verifyImportedValuesExist(dep, targetDeps, targetPath, sourcePath, erro
136
140
  if (dep.varName && dep.varName.startsWith('*')) {
137
141
  return
138
142
  }
143
+ // Side-effect imports (`import './foo.js'`) bind no values — no export required.
144
+ if (!dep.varName && !dep.definitions) {
145
+ return
146
+ }
147
+ // Dynamic imports (`import('./foo.js')`) resolve to the module's
148
+ // namespace object, not the default export.
149
+ if (dep.isDynamic) {
150
+ return
151
+ }
139
152
  // ESM import X from 'pkg' requires a default export
140
153
  const hasModuleExports = targetDeps.moduleExports && targetDeps.moduleExports.length > 0
141
154
  const hasESMExports = targetDeps.exports && targetDeps.exports.length > 0
@@ -1,6 +1,6 @@
1
1
  // Extracts CommonJS module.exports and exports patterns from JavaScript code
2
2
 
3
- const { getNames, extractExportName } = require('./utils')
3
+ const { getNames, extractExportName, stripStringContents } = require('./utils')
4
4
 
5
5
  // Matches: module.exports = ... or exports = module.exports = ...
6
6
  // https://regex101.com/r/example/cjs-1
@@ -35,28 +35,44 @@ function stripComments(code) {
35
35
  }
36
36
 
37
37
  function getCjsExports(code) {
38
+ // Blank string/template literal contents before stripping comments so
39
+ // `exports.X = ...` lines embedded inside template literals aren't matched
40
+ // as real exports, and so the comment regex doesn't mis-strip `//` inside
41
+ // string bodies.
42
+ code = stripStringContents(code)
38
43
  // Strip comments to prevent false positives from commented code
39
44
  const sanitizedCode = stripComments(code)
40
45
 
41
46
  const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
42
47
  const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
43
48
  const propertyExports = getExportsProperty(code, sanitizedCode)
44
-
49
+
45
50
  // Quick pre-checks to avoid expensive regex operations when not needed
46
51
  const objectAssignExports = sanitizedCode.includes('Object.assign')
47
52
  ? getObjectAssignExports(code, sanitizedCode)
48
53
  : []
49
-
54
+
50
55
  const definePropertyExports = sanitizedCode.includes('Object.defineProperty')
51
56
  ? getDefinePropertyExports(code, sanitizedCode)
52
57
  : []
53
58
 
59
+ // Detect dynamic CJS re-export-all patterns emitted by Rollup, tsc, esbuild,
60
+ // and friends. Cheap substring guard first to avoid the regex work on files
61
+ // that obviously can't contain these patterns.
62
+ const reExportAllExports =
63
+ sanitizedCode.includes('Object.keys(') ||
64
+ sanitizedCode.includes('__exportStar') ||
65
+ sanitizedCode.includes('__reExport')
66
+ ? getReExportAllPatterns(code, sanitizedCode)
67
+ : []
68
+
54
69
  /*
55
70
  console.log('assignmentExports', assignmentExports)
56
71
  console.log('modulePropertyExports', modulePropertyExports)
57
72
  console.log('propertyExports', propertyExports)
58
73
  console.log('objectAssignExports', objectAssignExports)
59
74
  console.log('definePropertyExports', definePropertyExports)
75
+ console.log('reExportAllExports', reExportAllExports)
60
76
  /** */
61
77
 
62
78
  const allExports = []
@@ -65,6 +81,7 @@ function getCjsExports(code) {
65
81
  .concat(propertyExports)
66
82
  .concat(objectAssignExports)
67
83
  .concat(definePropertyExports)
84
+ .concat(reExportAllExports)
68
85
 
69
86
  return allExports.sort((a, b) => a.index - b.index)
70
87
  }
@@ -372,6 +389,83 @@ function getDefinePropertyExports(originalText = '', sanitizedText = originalTex
372
389
  return exportStatements
373
390
  }
374
391
 
392
+ /**
393
+ * Detect dynamic CJS re-export-all patterns where a module forwards every
394
+ * named export of another module at runtime. Bundlers emit these for ESM
395
+ * `export * from './x'` syntax when targeting CJS:
396
+ *
397
+ * - Rollup: `Object.keys(X).forEach(function(k){ if (k!=='default' && ...) exports[k]=X[k] })`
398
+ * - tsc/tslib: `__exportStar(require('./x'), exports)` / `tslib.__exportStar(...)`
399
+ * - esbuild: `__reExport(stdin_exports, require('./x'))`
400
+ *
401
+ * We don't try to resolve the require and merge names — that's recursive and
402
+ * filesystem-dependent. Instead each match emits `exportType: 'all'`, the same
403
+ * marker `get-exports.js` uses for ESM `export * from`. Downstream validators
404
+ * already skip name-verification when they see this marker, so importing a
405
+ * name that's only available via dynamic re-export no longer false-positives.
406
+ *
407
+ * Captures the require path or source-variable name into `from` when visible,
408
+ * for debugging and future resolve-and-merge work.
409
+ */
410
+ function getReExportAllPatterns(originalText = '', sanitizedText = originalText) {
411
+ const exportStatements = []
412
+
413
+ // Rollup `Object.keys(X).forEach(function(k){ ... exports[k] = X[k] ... })`.
414
+ // Allow either `function (k)` or `(k) =>` and any condition on `k` inside.
415
+ 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
416
+ let result
417
+ while ((result = rollupPattern.exec(sanitizedText)) !== null) {
418
+ const sourceVar = result[1]
419
+ exportStatements.push({
420
+ line: result[0].split('\n')[0].trim(),
421
+ exportType: 'all',
422
+ exportName: '',
423
+ exportValue: result[0],
424
+ from: sourceVar,
425
+ index: result.index,
426
+ isExport: true,
427
+ isReExportAll: true
428
+ })
429
+ }
430
+
431
+ // tslib / tsc `__exportStar(<source>, exports)` where source is either
432
+ // a `require('./x')` call or a bare identifier holding a prior require.
433
+ const exportStarPattern = /(?:\w+\.)?__exportStar\s*\(\s*(?:require\s*\(\s*['"]([^'"]+)['"]\s*\)|(\w+))\s*,\s*(?:module\.)?exports\s*\)/g
434
+ while ((result = exportStarPattern.exec(sanitizedText)) !== null) {
435
+ const from = result[1] || result[2]
436
+ exportStatements.push({
437
+ line: result[0].trim(),
438
+ exportType: 'all',
439
+ exportName: '',
440
+ exportValue: result[0],
441
+ from,
442
+ index: result.index,
443
+ isExport: true,
444
+ isReExportAll: true
445
+ })
446
+ }
447
+
448
+ // esbuild `__reExport(<exports-target>, <source>)`. The exports-target is
449
+ // typically an identifier ending in `_exports` or `exports`, and the source
450
+ // is again a `require()` call or an identifier.
451
+ const reExportPattern = /__reExport\s*\(\s*\w+\s*,\s*(?:require\s*\(\s*['"]([^'"]+)['"]\s*\)|(\w+))\s*(?:,\s*[^)]*)?\)/g
452
+ while ((result = reExportPattern.exec(sanitizedText)) !== null) {
453
+ const from = result[1] || result[2]
454
+ exportStatements.push({
455
+ line: result[0].trim(),
456
+ exportType: 'all',
457
+ exportName: '',
458
+ exportValue: result[0],
459
+ from,
460
+ index: result.index,
461
+ isExport: true,
462
+ isReExportAll: true
463
+ })
464
+ }
465
+
466
+ return exportStatements
467
+ }
468
+
375
469
  if (require.main === module) {
376
470
  const code = `
377
471
  // module.exports assignment
@@ -1,4 +1,4 @@
1
- const { getNames, extractExportName, stripComments } = require('./utils')
1
+ const { getNames, extractExportName, stripComments, stripStringContents } = 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,6 +16,10 @@ 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
+ // Blank string/template contents first so embedded `export` keywords inside
20
+ // string literals don't get parsed as real exports, and so `stripComments`
21
+ // doesn't mis-strip `//` or `/* */` that appear inside strings.
22
+ code = stripStringContents(code)
19
23
  // Strip comments to avoid false positives
20
24
  code = stripComments(code)
21
25
  const defaultExports = getDefaultExports(code)
@@ -1,15 +1,47 @@
1
- const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, stripComments } = require('./utils')
1
+ const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, stripComments, stripStringContents } = 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
+ // Leading anchor `(?:^|(?<=[;\n]))` requires `import` to start a line (the `^`
16
+ // matches at every line start under the `m` flag) or follow a `;` on the same
17
+ // line. Without this, string literals like `'note: import preview'` get parsed
18
+ // as `import preview` — the old regex was safe from this only because of its
19
+ // `$` anchor, which we removed to support multi-statement lines.
20
+ const IMPORT_REGEX = /(?:^|(?<=[;\n]))\s*import(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["'](?!\s*(?:assert|with)\s*\{)/gm
5
21
  // https://regex101.com/r/UDmXlb/1
6
22
  const IMPORT_TYPE_REGEX = /import\(['"](.*)['"]\)\.?([A-Za-z_0-9.]*)?/gm
7
23
  // 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
24
+ // `await` is optional so `import('./foo').then(...)`, `const p = import('./foo')`,
25
+ // `return import('./foo')`, and `lazy(() => import('./foo'))` all match.
26
+ // `(?<!\.)\b` rejects member access (`obj.import(...)`) and identifiers like
27
+ // `myimport(...)`.
28
+ //
29
+ // TypeScript inline-import-type forms (`: import('./x').Foo`, `typeof
30
+ // import('./x')`) are matched here too. They get `isDynamic: true` and the
31
+ // validators already skip default-export checks for dynamic imports, so they
32
+ // produce no false positives. The relationship is real for compilation
33
+ // (renaming/deleting the target breaks `tsc`), so keeping the dep-graph edge
34
+ // is the more conservative choice for smart-ci's change detection.
35
+ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)?(?:await\s+)?(?<!\.)\bimport\(([^()]+(?:\([^()]*\)[^()]*)*)\)/gm
9
36
  // 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
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
11
38
 
12
39
  function getImports(code = '', options = {}) {
40
+ // Blank string/template contents first so the comment stripper doesn't
41
+ // mis-strip `//` or `/* */` sequences inside literals, and so the regex
42
+ // doesn't pick up `import`/`export` keywords that appear inside strings
43
+ // (e.g. fenced code in JSDoc-style template literals).
44
+ code = stripStringContents(code)
13
45
  // Strip comments to avoid false positives
14
46
  code = stripComments(code)
15
47
  const staticImports = getStaticImports(code)
@@ -26,7 +58,14 @@ function getStaticImports(targetText = '') {
26
58
  let importStatements = []
27
59
  var result;
28
60
  while((result = IMPORT_REGEX.exec(targetText)) !== null) {
29
- const [ line, names, from ] = result
61
+ let [ line, names, rawFrom ] = result
62
+ // The leading `(?:^|(?<=[;\n]))\s*` anchor pulls in any whitespace/newline
63
+ // separator that precedes the `import` keyword; trim it back off so `line`
64
+ // reads as the source author wrote it.
65
+ line = line.replace(/^\s+/, '')
66
+ // Side-effect imports (e.g. `import './foo.js'`) and unnamed-default imports skip the
67
+ // optional `from` capture, so the opening quote ends up inside the path group. Strip it.
68
+ const from = rawFrom.replace(/^["'`]|["'`]$/g, '')
30
69
  // Skip TypeScript type-only imports — erased at compile time, no runtime resolution needed
31
70
  if (/^\s*import\s+type\s/.test(line)) {
32
71
  continue
@@ -104,7 +143,8 @@ function getAssertionImports(targetText = '') {
104
143
  let importStatements = []
105
144
  var result;
106
145
  while((result = ASSERTION_IMPORT_REGEX.exec(targetText)) !== null) {
107
- let [ line, names, from, keyword, assertion ] = result
146
+ let [ line, names, rawFrom, keyword, assertion ] = result
147
+ const from = rawFrom.replace(/^["'`]|["'`]$/g, '')
108
148
  const namesValues = (names || '').trim()
109
149
  // Remove leading newlines and whitespace from the line
110
150
  line = line.replace(/^\s+|\s+$/g, '')
@@ -1,4 +1,4 @@
1
- const { isBuiltIn, isRelativeImport, getNames } = require('./utils')
1
+ const { isBuiltIn, isRelativeImport, getNames, stripComments, stripStringContents } = 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
@@ -65,6 +65,11 @@ function getStaticRequires(targetText = '') {
65
65
  }
66
66
 
67
67
  function getRequires(code) {
68
+ // Blank string/template contents first so `require()` calls embedded in
69
+ // string literals (e.g. fenced code in template strings) aren't matched as
70
+ // real requires. Then strip comments so `// require('./fake')` is ignored.
71
+ code = stripStringContents(code)
72
+ code = stripComments(code)
68
73
  const staticRequires = getStaticRequires(code)
69
74
  const dynamicRequires = getDynamicRequires(code)
70
75
  return staticRequires.concat(dynamicRequires).sort((a, b) => a.index - b.index)
package/src/utils.js CHANGED
@@ -4,6 +4,65 @@ const builtinModules = require('module').builtinModules
4
4
  const COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/.*(?:\r?\n|$)/g
5
5
  const PROTOCOL_PLACEHOLDER = '__PROTO_SLASH__'
6
6
 
7
+ // Single-quoted and double-quoted strings can't contain unescaped newlines
8
+ // (per JS spec — they'd need `\n` escapes or `\<NL>` line continuations). The
9
+ // `[^"\\\n]` / `[^'\\\n]` char classes enforce that, so an unbalanced quote
10
+ // followed by a newline doesn't sweep up the next several lines as "string".
11
+ // Template literals (`` `...` ``) DO span newlines, so their inner class only
12
+ // excludes backtick and backslash. Escape sequences (`\.`) are allowed in all
13
+ // three so escaped quotes don't prematurely end the match.
14
+ // Note: this treats `${...}` interpolations as part of the string body — code
15
+ // inside `${...}` (rare, but possible: `${import('./x.js')}`) gets blanked too.
16
+ // Acceptable for the change-detection use case; a full tokenizer would be
17
+ // needed to preserve interpolations.
18
+ const STRING_LITERAL_RE = /"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
19
+
20
+ // Strings whose bodies contain these tokens are treated as embedded code
21
+ // samples (template strings, generator helpers, fenced docs) and get blanked.
22
+ // Plain module specifiers like `'lodash'` or `'./foo.js'` don't contain any
23
+ // of these as words, so they pass through untouched and remain extractable.
24
+ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
25
+
26
+ /**
27
+ * Blank the contents of every string/template literal in `code` that looks
28
+ * like it contains embedded code (its body mentions `import`, `export`,
29
+ * `require`, or `module.exports`). The opening and closing quote chars are
30
+ * preserved, as are newlines. Total length is preserved so character indices
31
+ * remain stable.
32
+ *
33
+ * Strings whose bodies don't reference those keywords — including ordinary
34
+ * module specifiers like `'lodash'`, paths like `'./foo.js'`, URLs, and
35
+ * arbitrary user text — pass through untouched, so the static-import/require
36
+ * regexes can still read their content.
37
+ *
38
+ * Without this pass, regex-based extractors falsely match `import`, `export`,
39
+ * and `require` keywords that appear inside code samples embedded in strings.
40
+ *
41
+ * Run this BEFORE `stripComments` — the existing comment regex would otherwise
42
+ * mis-strip `//` and `/* *\/` sequences that occur inside strings.
43
+ *
44
+ * Caveat: `${...}` interpolations inside template literals are treated as
45
+ * part of the body, so a runtime `${import('./x.js')}` inside a template
46
+ * would be blanked. Very rare in practice; a proper tokenizer would be
47
+ * needed to do better.
48
+ *
49
+ * @param {string} code
50
+ * @returns {string}
51
+ */
52
+ function stripStringContents(code) {
53
+ return code.replace(STRING_LITERAL_RE, (match) => {
54
+ const body = match.slice(1, -1)
55
+ if (!EMBEDDED_CODE_RE.test(body)) {
56
+ // Plain string — leave it alone so module specifiers stay extractable.
57
+ return match
58
+ }
59
+ const opener = match[0]
60
+ const closer = match[match.length - 1]
61
+ const middle = body.replace(/[^\n]/g, ' ')
62
+ return opener + middle + closer
63
+ })
64
+ }
65
+
7
66
  /**
8
67
  * Strip comments from code, preserving :// in URLs
9
68
  * @param {string} code
@@ -232,6 +291,7 @@ function extractExportName(exportValue) {
232
291
 
233
292
  module.exports = {
234
293
  stripComments,
294
+ stripStringContents,
235
295
  isBuiltIn,
236
296
  isRelativeImport,
237
297
  isRelativeImportWithQuotes,
@@ -592,6 +592,66 @@ test('namespace import should build valid graph', () => {
592
592
  assert.ok(deps.some(d => d.includes('utils.js')), 'Should have utils.js as dependency')
593
593
  })
594
594
 
595
+ // Side-effect imports — `import './register.js'` runs the module for its
596
+ // effects and binds no values. The validator must not require a default export.
597
+ const ESM_SIDE_EFFECT_FIXTURE = path.resolve(__dirname, './fixtures/esm-side-effect-fixture/index.js')
598
+
599
+ test('side-effect import should not require default export', () => {
600
+ assert.not.throws(() => {
601
+ depGraph(ESM_SIDE_EFFECT_FIXTURE, { verifyGraph: true })
602
+ }, "import './x' should not fail when target has only named exports")
603
+ })
604
+
605
+ test('side-effect import should still build the dep edge', () => {
606
+ const graph = depGraph(ESM_SIDE_EFFECT_FIXTURE, { verifyGraph: true })
607
+ const indexPath = Array.from(graph.keys()).find(key => key.includes('esm-side-effect-fixture/index.js'))
608
+ assert.ok(indexPath, 'Should have index.js in graph')
609
+
610
+ const deps = graph.get(indexPath)
611
+ assert.ok(deps.some(d => d.includes('register.js')), 'Should have register.js as dependency')
612
+ })
613
+
614
+ // Dynamic imports — `await import('./x.js')` resolves to the namespace
615
+ // object, not the default export. Same skip semantics.
616
+ const ESM_DYNAMIC_FIXTURE = path.resolve(__dirname, './fixtures/esm-dynamic-import-fixture/index.js')
617
+
618
+ test('dynamic import should not require default export', () => {
619
+ assert.not.throws(() => {
620
+ depGraph(ESM_DYNAMIC_FIXTURE, { verifyGraph: true })
621
+ }, "import('./x') should not fail when target has only named exports")
622
+ })
623
+
624
+ // End-to-end coverage of the string/template/comment noise that historically
625
+ // produced false-positive dep edges. The fixture contains real + fake
626
+ // imports, requires, and exports embedded in template literals, double-
627
+ // quoted strings, single-quoted strings, escaped-quote strings, tagged
628
+ // templates, JSON-shaped strings, and comments. Only ONE real edge should
629
+ // be produced (→ real.js), and no errors should fire.
630
+ const STRING_NOISE_FIXTURE = path.resolve(__dirname, './fixtures/string-noise-fixture/index.js')
631
+
632
+ test('string/template/comment noise produces no false-positive edges', () => {
633
+ let graph
634
+ assert.not.throws(() => {
635
+ graph = depGraph(STRING_NOISE_FIXTURE, { verifyGraph: true })
636
+ }, 'should not throw on the noise fixture')
637
+
638
+ const indexPath = Array.from(graph.keys()).find(k => k.includes('string-noise-fixture/index.js'))
639
+ assert.ok(indexPath, 'index.js should be in the graph')
640
+
641
+ const deps = graph.get(indexPath)
642
+ assert.equal(deps.length, 1, 'should produce exactly one edge — the real import')
643
+ assert.ok(deps[0].includes('real.js'), 'the one edge should be to real.js')
644
+ })
645
+
646
+ test('dynamic import should still build the dep edge', () => {
647
+ const graph = depGraph(ESM_DYNAMIC_FIXTURE, { verifyGraph: true })
648
+ const indexPath = Array.from(graph.keys()).find(key => key.includes('esm-dynamic-import-fixture/index.js'))
649
+ assert.ok(indexPath, 'Should have index.js in graph')
650
+
651
+ const deps = graph.get(indexPath)
652
+ assert.ok(deps.some(d => d.includes('pipeline.js')), 'Should have pipeline.js as dependency')
653
+ })
654
+
595
655
  // TypeScript patterns fixture — exercises all fixed false positive patterns
596
656
  const TS_PATTERNS_FIXTURE = path.resolve(__dirname, './fixtures/ts-patterns-fixture/index.ts')
597
657
 
@@ -0,0 +1,11 @@
1
+ // Dynamic import resolves to the module's namespace object, not the
2
+ // default export. The target has only named exports and no default;
3
+ // validation must not flag this as "missing default export".
4
+ export async function load() {
5
+ const pipeline = await import('./pipeline.js')
6
+ return pipeline.run()
7
+ }
8
+
9
+ export function eager() {
10
+ return import('./pipeline.js').then((m) => m.run())
11
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "esm-dynamic-import-fixture",
3
+ "type": "module"
4
+ }
@@ -0,0 +1,6 @@
1
+ // Named exports only — no default. Dynamic importers should still validate.
2
+ export function run() {
3
+ return 'pipeline result'
4
+ }
5
+
6
+ export const VERSION = 1
@@ -0,0 +1,7 @@
1
+ // Importing './register.js' for its side effects only — no values are bound.
2
+ // The target file has no default export; that must be fine.
3
+ import './register.js'
4
+
5
+ export function main() {
6
+ return 'hello'
7
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "esm-side-effect-fixture",
3
+ "type": "module"
4
+ }
@@ -0,0 +1,6 @@
1
+ // Pure side-effect module — registers something on a shared registry and
2
+ // exports a single named binding. Critically, no default export.
3
+ const registry = (globalThis.__test_registry = globalThis.__test_registry || [])
4
+ registry.push('side-effect-fixture')
5
+
6
+ export const REGISTERED = true
@@ -0,0 +1,50 @@
1
+ // Regression fixture: every kind of string/template/comment noise that
2
+ // previously fooled the extractors, alongside one real import edge that
3
+ // MUST still be detected. The dep-graph test for this fixture asserts that
4
+ // exactly one edge (→ real.js) is produced.
5
+
6
+ import { realThing } from './real.js'
7
+
8
+ // --- Single-line comment with a fake import that must be ignored ---
9
+ // import fakeCommented from './fake-commented.js'
10
+
11
+ /*
12
+ * Multi-line comment with fake imports/requires inside
13
+ * import fakeMultiline from './fake-multiline.js'
14
+ * const fakeReq = require('./fake-multiline-require.js')
15
+ */
16
+
17
+ // --- Template literal with embedded code (the original user-reported case) ---
18
+ const codeSample = `
19
+ import fakeStatic from './fake-template-static.js'
20
+ await import('./fake-template-dynamic.js')
21
+ const fakeReq = require('./fake-template-require.js')
22
+ module.exports.fakeCjs = 'fake'
23
+ export const fakeEsm = 'fake'
24
+ `
25
+
26
+ // --- Double-quoted string holding a code snippet (the astro/config case) ---
27
+ const astroConfigSnippet = "import { defineConfig } from 'astro/config'"
28
+
29
+ // --- Single-quoted string mentioning the word `import` in prose ---
30
+ const docHint = 'Tip: run import preview from the dashboard'
31
+
32
+ // --- URL containing keyword-like words (passes through unchanged) ---
33
+ const docsUrl = 'https://npmjs.com/package/some-pkg'
34
+
35
+ // --- Escaped quotes inside strings ---
36
+ const escapedDq = "He said \"import fakeEscaped from './fake.js'\""
37
+ const escapedSq = 'She said \'export const fakeEsc = 1\''
38
+
39
+ // --- Adjacent strings on a single line ---
40
+ const adjacent = 'import a' + ' from ' + '"./fake.js"'
41
+
42
+ // --- Tagged template literal — still a template literal ---
43
+ const html = String.raw`<pre>import fakeTagged from './fake-tagged.js'</pre>`
44
+
45
+ // --- JSON-like string with an import-related package name ---
46
+ const pkgJson = '{ "name": "import-utils", "main": "./lib/index.js" }'
47
+
48
+ export function run() {
49
+ return realThing()
50
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "string-noise-fixture",
3
+ "type": "module"
4
+ }
@@ -0,0 +1,3 @@
1
+ export function realThing() {
2
+ return 'real'
3
+ }
@@ -759,4 +759,114 @@ 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
+
854
+ test('ignores CJS exports embedded in template literals and strings', () => {
855
+ const code = `
856
+ module.exports.realName = 'real'
857
+ exports.alsoReal = 'real'
858
+
859
+ const template = \`
860
+ module.exports.fakeName = 'fake'
861
+ exports.alsoFake = 'fake'
862
+ \`
863
+
864
+ const dq = "module.exports.stringFake = 'fake'"
865
+ `
866
+
867
+ const found = getCjsExports(code)
868
+ const names = found.map(e => e.exportName).filter(Boolean).sort()
869
+ assert.equal(names, ['alsoReal', 'realName'], 'Only the real CJS exports should be detected')
870
+ })
871
+
762
872
  test.run()
@@ -563,4 +563,21 @@ export const real = 'value' // this comment should be stripped
563
563
  assert.equal(foundExports[2].exportName, 'real', 'Should find real export')
564
564
  })
565
565
 
566
+ test('ignores ESM exports embedded in template literals and strings', () => {
567
+ const code = `
568
+ export const real = 'real'
569
+
570
+ const template = \`
571
+ export const fake = 'fake'
572
+ export default function fakeDefault() {}
573
+ \`
574
+
575
+ const dq = "export { foo } from 'fake'"
576
+ `
577
+
578
+ const found = getExports(code)
579
+ const names = found.map(e => e.exportName).filter(Boolean).sort()
580
+ assert.equal(names, ['real'], 'Only the real export should be detected')
581
+ })
582
+
566
583
  test.run()
@@ -536,4 +536,249 @@ 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('ignores import statements embedded in template-literal strings', () => {
581
+ // Regression: code samples embedded in template literals (codegen, doc
582
+ // strings, test fixtures) were being parsed as real imports. The
583
+ // `stripStringContents` pass in `getImports` blanks those bodies first.
584
+ const code = `
585
+ import real from './real.js'
586
+
587
+ const template = \`
588
+ import fakeStatic from './fake-static.js'
589
+ await import('./fake-dynamic.js')
590
+ const fakeFs = require('./fake-fs.js')
591
+ \`
592
+
593
+ const code = "import { foo } from 'fake-double-quoted'"
594
+ const more = 'import bar from "fake-single-quoted"'
595
+ `
596
+
597
+ const importsFound = normalizeTestResult(getImports(code))
598
+ const froms = importsFound.map(i => i.from).sort()
599
+ assert.equal(froms, ['./real.js'], 'Only the real top-level import should match')
600
+ })
601
+
602
+ test('preserves module specifiers — plain strings without code keywords pass through', () => {
603
+ // The string-stripping heuristic only blanks string bodies that contain
604
+ // import/export/require/module.exports keywords. Plain module specifiers
605
+ // like 'lodash' or './foo.js' must NOT be touched, or `getImports` would
606
+ // lose its `from` value entirely.
607
+ const code = `
608
+ import lodash from 'lodash'
609
+ import foo from './foo.js'
610
+ import url from 'https://esm.sh/some-pkg'
611
+ import scoped from '@scope/pkg/sub/path'
612
+ `
613
+
614
+ const importsFound = normalizeTestResult(getImports(code))
615
+ const froms = importsFound.map(i => i.from).sort()
616
+ assert.equal(froms, [
617
+ '../lodash'.replace('../', ''),
618
+ './foo.js',
619
+ '@scope/pkg/sub/path',
620
+ 'https://esm.sh/some-pkg',
621
+ ].sort())
622
+ })
623
+
624
+ test('does not match the word `import` inside string literals', () => {
625
+ // Regression: dropping the trailing `$` anchor (to enable multi-statement
626
+ // lines) let any quoted string containing the word `import` followed by
627
+ // text and a closing quote get parsed as a real import. The fix anchors
628
+ // `import` to a line start or `;`, so `'note: import preview'` is ignored.
629
+ const code = `
630
+ import { handler as importPreviewHandler } from '../functions/contacts/import-preview.js'
631
+ const route = createRoute({
632
+ summary: 'Contacts — import preview',
633
+ description: 'Triggers the import flow',
634
+ })
635
+ const msg = 'See docs: import preview from your dashboard'
636
+ `
637
+
638
+ const importsFound = normalizeTestResult(getStaticImports(code))
639
+ assert.equal(importsFound.length, 1, 'Should only find the real import')
640
+ assert.equal(importsFound[0].from, '../functions/contacts/import-preview.js')
641
+ assert.equal(importsFound[0].varName, '{ handler as importPreviewHandler }')
642
+ })
643
+
644
+ test('matches indented imports without leaking whitespace into `line`', () => {
645
+ // The anchor regex consumes any leading whitespace/newline before `import`;
646
+ // make sure that doesn't bleed into the captured `line` string.
647
+ const code = `
648
+ import foo from 'foo'
649
+ \timport bar from 'bar'
650
+ `
651
+
652
+ const importsFound = normalizeTestResult(getStaticImports(code))
653
+ assert.equal(importsFound.length, 2)
654
+ assert.equal(importsFound[0].line, "import foo from 'foo'")
655
+ assert.equal(importsFound[1].line, "import bar from 'bar'")
656
+ })
657
+
658
+ test('multiple static imports on a single line', () => {
659
+ // Regression: the `(.*[@\w_-]+)["'\s]+;?$` pattern was greedy plus EOL-anchored,
660
+ // so a line like `import a from 'a'; import b from 'b';` matched as ONE import
661
+ // whose path was `a'; import b from 'b`. Tightening the path char-class and
662
+ // dropping the `$` anchor turns this into separate matches.
663
+ const code = `import a from 'a'; import b from 'b'; import c from 'c';`
664
+ const importsFound = normalizeTestResult(getStaticImports(code))
665
+
666
+ assert.equal(importsFound.length, 3, 'Should find 3 imports on one line')
667
+ assert.equal(importsFound[0].from, 'a')
668
+ assert.equal(importsFound[1].from, 'b')
669
+ assert.equal(importsFound[2].from, 'c')
670
+
671
+ // Verify names parsed independently too
672
+ assert.equal(importsFound[0].varName, 'a')
673
+ assert.equal(importsFound[1].varName, 'b')
674
+ assert.equal(importsFound[2].varName, 'c')
675
+ })
676
+
677
+ test('static + assertion imports on the same line do not double-count', () => {
678
+ // The static IMPORT_REGEX uses a negative lookahead for `assert {` / `with {`
679
+ // so assertion imports flow only through ASSERTION_IMPORT_REGEX instead of
680
+ // being matched by both.
681
+ const code = `
682
+ import foo from 'fs'
683
+ import data from './data.json' assert { type: 'json' }
684
+ import styles from './styles.css' with { type: 'css' }
685
+ `
686
+
687
+ const all = normalizeTestResult(getImports(code, { includeAssertionImports: true }))
688
+ assert.equal(all.length, 3, 'Should not double-count assertion imports')
689
+
690
+ // Static-only call must also skip the assertion lines (no partial path matches)
691
+ const staticOnly = normalizeTestResult(getStaticImports(code))
692
+ assert.equal(staticOnly.length, 1, 'Static regex should skip assertion-import lines')
693
+ assert.equal(staticOnly[0].from, 'fs')
694
+ })
695
+
696
+ test('dynamic imports without await are detected', () => {
697
+ // Regression: DYNAMIC_IMPORT_REGEX required `await\s+import(`, missing
698
+ // every lazy-load pattern. `await` is now optional.
699
+ const code = `
700
+ import('./then-chain.js').then(m => m)
701
+ const eager = import('./eager.js')
702
+ return import('./return.js')
703
+ const Comp = lazy(() => import('./Component.js'))
704
+ const [a, b] = await Promise.all([import('./a.js'), import('./b.js')])
705
+ `
706
+
707
+ const importsFound = normalizeTestResult(getDynamicImports(code))
708
+ const froms = importsFound.map(i => i.from).sort()
709
+
710
+ assert.equal(importsFound.length, 6, 'Should find 6 dynamic imports')
711
+ assert.equal(froms, [
712
+ './Component.js',
713
+ './a.js',
714
+ './b.js',
715
+ './eager.js',
716
+ './return.js',
717
+ './then-chain.js',
718
+ ].sort())
719
+
720
+ // All flagged as dynamic
721
+ assert.ok(importsFound.every(i => i.isDynamic), 'All should have isDynamic: true')
722
+ })
723
+
724
+ test('dynamic-import regex includes TypeScript inline-import-type expressions', () => {
725
+ // Intentionally inclusive: `import('./x').TypeName` and `typeof import('./x')`
726
+ // are type-only and erased at runtime, but the *file* relationship is real
727
+ // (renaming/deleting the target breaks `tsc`). Including them keeps the dep
728
+ // graph conservative for change-detection purposes. The validators already
729
+ // skip default-export checks for `isDynamic: true`, so this produces no
730
+ // false positives, and `tsc` is the right tool for runtime misuse.
731
+ const code = `
732
+ function approve(payload: unknown): import('../types.js').ArtifactTypeApproval {
733
+ return payload as any
734
+ }
735
+ type Cfg = typeof import('./config.js')
736
+ type Promised = import('./x.js').Then
737
+ const real = import('./real.js')
738
+ const chain = import('./api.js').then(m => m.run())
739
+ `
740
+
741
+ const importsFound = getDynamicImports(code)
742
+ const froms = importsFound.map(i => i.from).sort()
743
+ assert.equal(
744
+ froms,
745
+ ['../types.js', './api.js', './config.js', './real.js', './x.js'].sort(),
746
+ 'Should match all import() forms — type-position and runtime alike'
747
+ )
748
+ // All flagged dynamic — validators key on this to skip default-export checks
749
+ assert.ok(importsFound.every(i => i.isDynamic))
750
+ })
751
+
752
+ test('dynamic-import regex rejects member access and identifier prefixes', () => {
753
+ // `obj.import('x')` and `myimport('x')` are not dynamic imports — the
754
+ // `(?<!\.)\b` guard prevents these from matching now that `await` is optional.
755
+ const code = `
756
+ obj.import('./not-an-import.js')
757
+ myimport('./also-not.js')
758
+ const u = import.meta.url
759
+ const real = import('./real.js')
760
+ `
761
+
762
+ const importsFound = getDynamicImports(code)
763
+ assert.equal(importsFound.length, 1, 'Should only match the real dynamic import')
764
+ assert.equal(importsFound[0].from, './real.js')
765
+ })
766
+
767
+ test('assertion imports strip surrounding quotes from `from`', () => {
768
+ // Same class of bug as static imports — the assertion regex captured the
769
+ // opening quote into the path group when the assertion was wrapped tightly.
770
+ const code = `
771
+ import data from './data.json' assert { type: 'json' }
772
+ import sheet from "./sheet.css" with { type: 'css' }
773
+ `
774
+
775
+ const importsFound = normalizeTestResult(getAssertionImports(code))
776
+
777
+ assert.equal(importsFound.length, 2)
778
+ assert.equal(importsFound[0].from, './data.json')
779
+ assert.is(importsFound[0].isRelative, true)
780
+ assert.equal(importsFound[1].from, './sheet.css')
781
+ assert.is(importsFound[1].isRelative, true)
782
+ })
783
+
539
784
  test.run()
@@ -268,4 +268,22 @@ test('should still match real requires mixed with non-require code', async () =>
268
268
  assert.equal(staticRequires[2].from, 'side-effect-only')
269
269
  })
270
270
 
271
+ test('ignores requires embedded in template literals, strings, and comments', () => {
272
+ const code = `
273
+ const real = require('./real.js')
274
+
275
+ const template = \`
276
+ const fake = require('./fake-template.js')
277
+ \`
278
+
279
+ // const commented = require('./commented.js')
280
+
281
+ const dq = "const fakeStr = require('./fake-string.js')"
282
+ `
283
+
284
+ const found = getRequires(code)
285
+ const froms = found.map(r => r.from).sort()
286
+ assert.equal(froms, ['./real.js'], 'Only the real require should match')
287
+ })
288
+
271
289
  test.run()
@@ -0,0 +1,145 @@
1
+ const { test } = require('uvu')
2
+ const assert = require('uvu/assert')
3
+ const { stripStringContents, stripComments } = require('../src/utils')
4
+
5
+ // `stripStringContents` blanks the bodies of string/template literals whose
6
+ // content references `import`/`export`/`require`/`module.exports`. Plain
7
+ // strings pass through. Length and newlines are preserved.
8
+
9
+ test('stripStringContents — leaves plain module specifiers untouched', () => {
10
+ // The whole point: a `require('lodash')` call must keep `lodash` so the
11
+ // require regex can read the path. Blanking it would break extraction.
12
+ const input = `const x = require('lodash')\nimport foo from './foo.js'`
13
+ assert.equal(stripStringContents(input), input)
14
+ })
15
+
16
+ test('stripStringContents — blanks template literal that contains code', () => {
17
+ const input = '`\nimport fake from "./fake.js"\nexport const x = 1\n`'
18
+ const output = stripStringContents(input)
19
+
20
+ // Length preserved
21
+ assert.equal(output.length, input.length, 'should preserve total length')
22
+ // Newlines preserved
23
+ assert.equal(
24
+ (output.match(/\n/g) || []).length,
25
+ (input.match(/\n/g) || []).length,
26
+ 'should preserve newline count'
27
+ )
28
+ // The body must not still contain the keywords
29
+ assert.not.match(output, /import/)
30
+ assert.not.match(output, /export/)
31
+ // Opening/closing backticks survive
32
+ assert.is(output[0], '`')
33
+ assert.is(output[output.length - 1], '`')
34
+ })
35
+
36
+ test('stripStringContents — blanks double-quoted strings with code', () => {
37
+ const input = `const x = "import { defineConfig } from 'astro/config'"`
38
+ const output = stripStringContents(input)
39
+
40
+ assert.equal(output.length, input.length)
41
+ assert.not.match(output, /import/)
42
+ // Outer `"` chars preserved at the original positions
43
+ const openIdx = input.indexOf('"')
44
+ const closeIdx = input.lastIndexOf('"')
45
+ assert.is(output[openIdx], '"')
46
+ assert.is(output[closeIdx], '"')
47
+ })
48
+
49
+ test('stripStringContents — blanks single-quoted strings with code', () => {
50
+ const input = `const msg = 'export const fake = 1'`
51
+ const output = stripStringContents(input)
52
+ assert.equal(output.length, input.length)
53
+ assert.not.match(output, /export/)
54
+ })
55
+
56
+ test('stripStringContents — strings without code keywords are preserved verbatim', () => {
57
+ const cases = [
58
+ `const x = 'hello world'`,
59
+ `const u = 'https://example.com/path/to/thing.json'`,
60
+ `const p = './some/path/with-dashes/and.dots'`,
61
+ `const t = \`plain template with \${interpolation}\``,
62
+ ]
63
+ for (const input of cases) {
64
+ assert.equal(stripStringContents(input), input, `should leave alone: ${input}`)
65
+ }
66
+ })
67
+
68
+ test('stripStringContents — handles escaped quotes inside strings', () => {
69
+ // `"He said \"import fake\""` — the escaped quote should NOT close the
70
+ // string early. The body contains `import`, so it gets blanked.
71
+ const input = `const m = "He said \\"import fakeEscaped from './fake.js'\\""`
72
+ const output = stripStringContents(input)
73
+ assert.equal(output.length, input.length)
74
+ assert.not.match(output, /import\s+fakeEscaped/)
75
+ // The trailing chars after the real closing quote should be unchanged
76
+ assert.is(output[output.length - 1], '"')
77
+ })
78
+
79
+ test('stripStringContents — unbalanced quote followed by newline is NOT a string', () => {
80
+ // Single/double-quoted strings can't cross newlines per JS spec. An
81
+ // unterminated quote followed by a newline must not start a multi-line
82
+ // sweep that swallows the next several lines.
83
+ const input = `import React from react"\n\nimport real from './real.js'`
84
+ const output = stripStringContents(input)
85
+ // The second `import real from './real.js'` MUST survive unchanged
86
+ assert.ok(output.includes("import real from './real.js'"))
87
+ })
88
+
89
+ test('stripStringContents — template literal across many lines blanks all of them', () => {
90
+ const input = '`line 1\nimport x from "./fake.js"\nline 3\n`'
91
+ const output = stripStringContents(input)
92
+ assert.equal(output.length, input.length)
93
+ // Inner `import` gone
94
+ assert.not.match(output, /import\s+x/)
95
+ // Newlines preserved so line numbers still line up
96
+ assert.equal((output.match(/\n/g) || []).length, 3)
97
+ })
98
+
99
+ test('stripStringContents — adjacent strings on one line are matched independently', () => {
100
+ // `'import' + ' from ' + '"./fake"'` — three separate strings.
101
+ // Only the first contains the `import` keyword, so only it is blanked.
102
+ const input = `const x = 'import' + ' from ' + '"./fake.js"'`
103
+ const output = stripStringContents(input)
104
+ assert.equal(output.length, input.length)
105
+ // The middle string ` from ` and last string `"./fake.js"` are preserved
106
+ assert.ok(output.includes("' from '"))
107
+ assert.ok(output.includes(`'"./fake.js"'`))
108
+ })
109
+
110
+ test('stripStringContents — tagged template literals are still blanked', () => {
111
+ // `String.raw\`<div>import fakeTagged from './fake.js'</div>\`` — the
112
+ // backtick portion is a template literal regardless of the tag prefix.
113
+ const input = "const html = String.raw`<div>import fakeTagged from './fake.js'</div>`"
114
+ const output = stripStringContents(input)
115
+ assert.equal(output.length, input.length)
116
+ assert.not.match(output, /import\s+fakeTagged/)
117
+ // The tag identifier `String.raw` must survive
118
+ assert.ok(output.includes('String.raw'))
119
+ })
120
+
121
+ test('stripStringContents — preserves :// URL strings (they have no code keywords)', () => {
122
+ const input = `const url = 'https://npmjs.com/package/import-utils'`
123
+ // Note: `import-utils` contains `import` followed by `-` (non-word).
124
+ // `\bimport\b` matches at that boundary, so the body IS blanked. This is
125
+ // a conservative blanking but it's harmless — we don't try to extract
126
+ // from inside the URL string anyway.
127
+ const output = stripStringContents(input)
128
+ assert.equal(output.length, input.length)
129
+ })
130
+
131
+ test('stripStringContents — neutralizes // inside code-bearing strings so stripComments cannot eat them', () => {
132
+ // For strings whose bodies DO trigger the heuristic (contain import/export
133
+ // etc.), the body becomes spaces and any `//` or `/* */` inside is gone
134
+ // before stripComments runs. Result: the surrounding code is preserved.
135
+ // Strings WITHOUT those keywords still hit the pre-existing `stripComments`
136
+ // limitation, which is outside the scope of this helper.
137
+ const input = `const x = "// import fakeCmt from './fake.js'"\nconst y = 1`
138
+ const after = stripComments(stripStringContents(input))
139
+ // The outer quoted-string structure must remain intact — `stripComments`
140
+ // would have eaten from `//` to end of line if we hadn't blanked first.
141
+ assert.ok(after.startsWith('const x = "'), 'opening of the string survives')
142
+ assert.ok(after.includes('"\nconst y = 1'), 'closing quote and following code survive')
143
+ })
144
+
145
+ test.run()