@davidwells/extract-deps 0.1.8 → 0.1.11

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.8",
3
+ "version": "0.1.11",
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
@@ -140,6 +140,15 @@ function verifyImportedValuesExist(dep, targetDeps, targetPath, sourcePath, erro
140
140
  if (dep.varName && dep.varName.startsWith('*')) {
141
141
  return
142
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
+ }
143
152
  // ESM import X from 'pkg' requires a default export
144
153
  const hasModuleExports = targetDeps.moduleExports && targetDeps.moduleExports.length > 0
145
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, stripCommentsAndStrings } = require('./utils')
4
4
 
5
5
  // Matches: module.exports = ... or exports = module.exports = ...
6
6
  // https://regex101.com/r/example/cjs-1
@@ -35,8 +35,11 @@ function stripComments(code) {
35
35
  }
36
36
 
37
37
  function getCjsExports(code) {
38
- // Strip comments to prevent false positives from commented code
39
- const sanitizedCode = stripComments(code)
38
+ // Single tokenizer-based pass that blanks comments and code-bearing
39
+ // strings/templates together. Length is preserved so the position-based
40
+ // logic below (which slices into the original `code` for full export
41
+ // values) keeps working off the same indices.
42
+ const sanitizedCode = stripCommentsAndStrings(code)
40
43
 
41
44
  const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
42
45
  const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
@@ -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,8 +16,12 @@ 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
- // Strip comments to avoid false positives
19
+ // Comments first, then strings — order matters. Running stripStringContents
20
+ // alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
21
+ // with a later quote and shift subsequent backtick pairing, which could
22
+ // swallow real `export` declarations a few lines later.
20
23
  code = stripComments(code)
24
+ code = stripStringContents(code)
21
25
  const defaultExports = getDefaultExports(code)
22
26
  const namedExports = getNamedExports(code)
23
27
  const namedDeclarationExports = getNamedDeclarationExports(code)
@@ -1,4 +1,4 @@
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
4
  // Path excludes quotes, newlines, and `;` so multi-statement lines like
@@ -12,7 +12,12 @@ const { isBuiltIn, isRelativeImport, isRelativeImportWithQuotes, getNames, strip
12
12
  // Negative lookahead `(?!\s*(?:assert|with)\s*\{)` keeps this from matching
13
13
  // import-attributes/assertion imports — those go through ASSERTION_IMPORT_REGEX
14
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
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
16
21
  // https://regex101.com/r/UDmXlb/1
17
22
  const IMPORT_TYPE_REGEX = /import\(['"](.*)['"]\)\.?([A-Za-z_0-9.]*)?/gm
18
23
  // https://regex101.com/r/UDjMoV/1
@@ -20,13 +25,24 @@ const IMPORT_TYPE_REGEX = /import\(['"](.*)['"]\)\.?([A-Za-z_0-9.]*)?/gm
20
25
  // `return import('./foo')`, and `lazy(() => import('./foo'))` all match.
21
26
  // `(?<!\.)\b` rejects member access (`obj.import(...)`) and identifiers like
22
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.
23
35
  const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)?(?:await\s+)?(?<!\.)\bimport\(([^()]+(?:\([^()]*\)[^()]*)*)\)/gm
24
36
  // https://regex101.com/r/example/1
25
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
26
38
 
27
39
  function getImports(code = '', options = {}) {
28
- // Strip comments to avoid false positives
40
+ // Strip comments FIRST so apostrophes/backticks inside JSDoc text get
41
+ // removed before `stripStringContents` looks for string literals.
42
+ // (Running the two in the opposite order let comment chars masquerade as
43
+ // string openers and swallow real `import`/`export` lines further down.)
29
44
  code = stripComments(code)
45
+ code = stripStringContents(code)
30
46
  const staticImports = getStaticImports(code)
31
47
  const dynamicImports = getDynamicImports(code)
32
48
  if (options.includeAssertionImports) {
@@ -41,7 +57,11 @@ function getStaticImports(targetText = '') {
41
57
  let importStatements = []
42
58
  var result;
43
59
  while((result = IMPORT_REGEX.exec(targetText)) !== null) {
44
- const [ line, names, rawFrom ] = result
60
+ let [ line, names, rawFrom ] = result
61
+ // The leading `(?:^|(?<=[;\n]))\s*` anchor pulls in any whitespace/newline
62
+ // separator that precedes the `import` keyword; trim it back off so `line`
63
+ // reads as the source author wrote it.
64
+ line = line.replace(/^\s+/, '')
45
65
  // Side-effect imports (e.g. `import './foo.js'`) and unnamed-default imports skip the
46
66
  // optional `from` capture, so the opening quote ends up inside the path group. Strip it.
47
67
  const from = rawFrom.replace(/^["'`]|["'`]$/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,12 @@ function getStaticRequires(targetText = '') {
65
65
  }
66
66
 
67
67
  function getRequires(code) {
68
+ // Strip comments first, then strings — order matters. `stripComments`
69
+ // alone would mis-eat `//` inside strings; running it first instead means
70
+ // comment chars (incl. apostrophes/backticks inside JSDoc) disappear
71
+ // before `stripStringContents` looks for real string literals.
72
+ code = stripComments(code)
73
+ code = stripStringContents(code)
68
74
  const staticRequires = getStaticRequires(code)
69
75
  const dynamicRequires = getDynamicRequires(code)
70
76
  return staticRequires.concat(dynamicRequires).sort((a, b) => a.index - b.index)
package/src/utils.js CHANGED
@@ -4,6 +4,118 @@ 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
+
66
+ // Unified regex matching whichever comes first at each position: block
67
+ // comment, line comment, single-quoted string, double-quoted string, or
68
+ // template literal. Matching left-to-right means a comment that starts
69
+ // first consumes its body — including any apostrophes or backticks inside
70
+ // — before they can start a spurious string match a few lines later. That
71
+ // solves the JSDoc-apostrophe-shifts-backtick-pairing class of bugs that
72
+ // arose when comments and strings were stripped in two separate passes.
73
+ const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
74
+
75
+ /**
76
+ * Single-regex pass that blanks comment bodies and code-bearing
77
+ * string/template bodies together. Solves two bugs that occurred when the
78
+ * string-strip and comment-strip ran in separate passes:
79
+ *
80
+ * - Apostrophes inside JSDoc comments (e.g. `* Netlify's \`...`) were
81
+ * mis-paired with later quotes, which shifted subsequent backtick
82
+ * pairing and could swallow real `export` declarations a few lines
83
+ * later in the same file.
84
+ * - `//` and `/* *\/` sequences inside string bodies were mis-stripped
85
+ * as comments.
86
+ *
87
+ * Length is preserved (each consumed char replaced by a space or newline
88
+ * kept verbatim) so downstream character indices stay valid.
89
+ *
90
+ * Strings whose bodies don't reference `import` / `export` / `require` /
91
+ * `module.exports` pass through unchanged, so module specifiers like
92
+ * `'lodash'` or `'./foo.js'` stay readable by the downstream regexes.
93
+ *
94
+ * Caveat: `${...}` interpolations inside template literals are part of the
95
+ * body, so `${import('./x.js')}` inside a template gets blanked. Rare in
96
+ * practice; a full parser would be needed otherwise.
97
+ *
98
+ * @param {string} code
99
+ * @returns {string}
100
+ */
101
+ function stripCommentsAndStrings(code) {
102
+ return code.replace(COMMENT_OR_STRING_RE, (match) => {
103
+ const first = match[0]
104
+ // Comments (line or block) are fully blanked — they have no extractable
105
+ // content. Newlines are preserved so line counts hold.
106
+ if (first === '/') {
107
+ return match.replace(/[^\n]/g, ' ')
108
+ }
109
+ // Strings/templates — only blank if the body references code keywords;
110
+ // otherwise leave the literal intact so `'lodash'` etc. remain readable.
111
+ const body = match.slice(1, -1)
112
+ if (!EMBEDDED_CODE_RE.test(body)) {
113
+ return match
114
+ }
115
+ return first + body.replace(/[^\n]/g, ' ') + first
116
+ })
117
+ }
118
+
7
119
  /**
8
120
  * Strip comments from code, preserving :// in URLs
9
121
  * @param {string} code
@@ -232,6 +344,8 @@ function extractExportName(exportValue) {
232
344
 
233
345
  module.exports = {
234
346
  stripComments,
347
+ stripStringContents,
348
+ stripCommentsAndStrings,
235
349
  isBuiltIn,
236
350
  isRelativeImport,
237
351
  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
+ }
@@ -851,4 +851,22 @@ Object.keys(config).forEach((k) => {
851
851
  assert.equal(reExports.length, 0, 'Should not match Object.keys patterns that do not assign to exports')
852
852
  })
853
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
+
854
872
  test.run()
@@ -563,4 +563,56 @@ 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('apostrophe inside JSDoc with surrounding backticks does not swallow later exports', () => {
567
+ // Regression: when `stripStringContents` ran BEFORE `stripComments`, an
568
+ // apostrophe inside a JSDoc block (e.g. `Netlify's \`promptType: 'foo'\``)
569
+ // was treated as the opener of a single-quoted string and consumed text up
570
+ // to the next `'`, shifting every subsequent backtick pair by one. The
571
+ // newly mis-paired template literal then spanned multiple lines, including
572
+ // the real `export async function` declaration that followed the JSDoc,
573
+ // and the embedded-code heuristic blanked it. The fix runs `stripComments`
574
+ // first so JSDoc-internal punctuation is gone before string matching.
575
+ //
576
+ // This fixture replicates the structural shape of the AWS SDK adapter file
577
+ // that originally surfaced the bug: a JSDoc block containing both an
578
+ // apostrophe and multiple backticks, followed by an `export async function`,
579
+ // followed by ANOTHER JSDoc with backticks.
580
+ const code = `
581
+ /**
582
+ * Stuff with \`code-one\`. Netlify's \`promptType: 'foo'\`. End \`Error\` on
583
+ * non-2xx upstream responses or malformed bodies.
584
+ */
585
+ export async function generateThing(request) {
586
+ return request
587
+ }
588
+ /** Test seam: override the global \`fetch\` for unit tests. */
589
+ export const helper = 1
590
+ `
591
+
592
+ const found = getExports(code)
593
+ const names = found.map(e => e.exportName).sort()
594
+ assert.equal(
595
+ names,
596
+ ['generateThing', 'helper'],
597
+ 'Both exports must survive past the JSDoc apostrophe+backtick combo'
598
+ )
599
+ })
600
+
601
+ test('ignores ESM exports embedded in template literals and strings', () => {
602
+ const code = `
603
+ export const real = 'real'
604
+
605
+ const template = \`
606
+ export const fake = 'fake'
607
+ export default function fakeDefault() {}
608
+ \`
609
+
610
+ const dq = "export { foo } from 'fake'"
611
+ `
612
+
613
+ const found = getExports(code)
614
+ const names = found.map(e => e.exportName).filter(Boolean).sort()
615
+ assert.equal(names, ['real'], 'Only the real export should be detected')
616
+ })
617
+
566
618
  test.run()
@@ -577,6 +577,84 @@ test('side-effect imports preserve definitions/varName semantics', () => {
577
577
  assert.is(imp.definitions, null, 'side-effect imports have null definitions')
578
578
  })
579
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
+
580
658
  test('multiple static imports on a single line', () => {
581
659
  // Regression: the `(.*[@\w_-]+)["'\s]+;?$` pattern was greedy plus EOL-anchored,
582
660
  // so a line like `import a from 'a'; import b from 'b';` matched as ONE import
@@ -643,6 +721,34 @@ const [a, b] = await Promise.all([import('./a.js'), import('./b.js')])
643
721
  assert.ok(importsFound.every(i => i.isDynamic), 'All should have isDynamic: true')
644
722
  })
645
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
+
646
752
  test('dynamic-import regex rejects member access and identifier prefixes', () => {
647
753
  // `obj.import('x')` and `myimport('x')` are not dynamic imports — the
648
754
  // `(?<!\.)\b` guard prevents these from matching now that `await` is optional.
@@ -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()