@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 +1 -1
- package/src/dep-graph.js +9 -0
- package/src/get-cjs-exports.js +6 -1
- package/src/get-exports.js +5 -1
- package/src/get-imports.js +24 -3
- package/src/get-requires.js +6 -1
- package/src/utils.js +60 -0
- package/tests/dep-graph.test.js +60 -0
- package/tests/fixtures/esm-dynamic-import-fixture/index.js +11 -0
- package/tests/fixtures/esm-dynamic-import-fixture/package.json +4 -0
- package/tests/fixtures/esm-dynamic-import-fixture/pipeline.js +6 -0
- package/tests/fixtures/esm-side-effect-fixture/index.js +7 -0
- package/tests/fixtures/esm-side-effect-fixture/package.json +4 -0
- package/tests/fixtures/esm-side-effect-fixture/register.js +6 -0
- package/tests/fixtures/string-noise-fixture/index.js +50 -0
- package/tests/fixtures/string-noise-fixture/package.json +4 -0
- package/tests/fixtures/string-noise-fixture/real.js +3 -0
- package/tests/get-cjs-exports.test.js +18 -0
- package/tests/get-exports.test.js +17 -0
- package/tests/get-imports.test.js +106 -0
- package/tests/get-requires.test.js +18 -0
- package/tests/utils.test.js +145 -0
package/package.json
CHANGED
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
|
package/src/get-cjs-exports.js
CHANGED
|
@@ -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
|
|
package/src/get-exports.js
CHANGED
|
@@ -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)
|
package/src/get-imports.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, '')
|
package/src/get-requires.js
CHANGED
|
@@ -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,
|
package/tests/dep-graph.test.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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()
|