@davidwells/extract-deps 0.1.8 → 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.8",
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
@@ -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, stripStringContents } = require('./utils')
4
4
 
5
5
  // Matches: module.exports = ... or exports = module.exports = ...
6
6
  // https://regex101.com/r/example/cjs-1
@@ -35,6 +35,11 @@ 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
 
@@ -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,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,11 +25,23 @@ 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 = {}) {
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)
28
45
  // Strip comments to avoid false positives
29
46
  code = stripComments(code)
30
47
  const staticImports = getStaticImports(code)
@@ -41,7 +58,11 @@ function getStaticImports(targetText = '') {
41
58
  let importStatements = []
42
59
  var result;
43
60
  while((result = IMPORT_REGEX.exec(targetText)) !== null) {
44
- const [ line, names, rawFrom ] = 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+/, '')
45
66
  // Side-effect imports (e.g. `import './foo.js'`) and unnamed-default imports skip the
46
67
  // optional `from` capture, so the opening quote ends up inside the path group. Strip it.
47
68
  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,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
+ }
@@ -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,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()
@@ -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()