@davidwells/extract-deps 0.1.12 → 0.1.13
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 +96 -12
- package/src/get-cjs-exports.js +5 -3
- package/src/get-exports.js +2 -0
- package/src/get-imports.js +2 -0
- package/src/get-requires.js +2 -0
- package/src/utils.js +20 -0
- package/tests/dep-graph.test.js +29 -1
package/package.json
CHANGED
package/src/dep-graph.js
CHANGED
|
@@ -6,6 +6,79 @@ const { resolve: resolveImport } = require('@dual-bundle/import-meta-resolve')
|
|
|
6
6
|
const { extractDepsShared: extractDeps } = require('./index')
|
|
7
7
|
const { isBuiltIn } = require('./utils')
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Parsed deps per file, keyed on mtime + size: callers walk the same shared
|
|
11
|
+
* files for every entry point (and a walk parses each import target twice:
|
|
12
|
+
* to verify the importer's names, then to recurse). Results are read-only.
|
|
13
|
+
* @type {Map<string, { key: string, deps: ReturnType<typeof extractDeps> }>}
|
|
14
|
+
*/
|
|
15
|
+
const parseCache = new Map()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} filePath
|
|
19
|
+
* @returns {ReturnType<typeof extractDeps>}
|
|
20
|
+
*/
|
|
21
|
+
function readDeps(filePath) {
|
|
22
|
+
const stat = fs.statSync(filePath)
|
|
23
|
+
const key = `${stat.mtimeMs}:${stat.size}`
|
|
24
|
+
const cached = parseCache.get(filePath)
|
|
25
|
+
if (cached && cached.key === key) return cached.deps
|
|
26
|
+
const deps = extractDeps(fs.readFileSync(filePath, 'utf8'))
|
|
27
|
+
parseCache.set(filePath, { key, deps })
|
|
28
|
+
return deps
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, string | null>} `${baseDir}\0${importPath}` -> resolved file (or null) */
|
|
32
|
+
const relativeResolveCache = new Map()
|
|
33
|
+
/** @type {Map<string, { url: string } | { error: Error }>} `${parentDir}\0${specifier}` -> package resolution */
|
|
34
|
+
const packageResolveCache = new Map()
|
|
35
|
+
/** @type {Map<string, string>} path -> realpath */
|
|
36
|
+
const realpathCache = new Map()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* fs.realpathSync, memoized per path.
|
|
40
|
+
* @param {string} filePath
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function realpath(filePath) {
|
|
44
|
+
let real = realpathCache.get(filePath)
|
|
45
|
+
if (real === undefined) {
|
|
46
|
+
real = fs.realpathSync(filePath)
|
|
47
|
+
realpathCache.set(filePath, real)
|
|
48
|
+
}
|
|
49
|
+
return real
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* resolveImport (import-meta-resolve) memoized per importing directory: a
|
|
54
|
+
* bare specifier resolves the same from every file in one directory.
|
|
55
|
+
* @param {string} specifier
|
|
56
|
+
* @param {string} parentPath - Importing file
|
|
57
|
+
* @returns {string} resolved URL
|
|
58
|
+
*/
|
|
59
|
+
function resolvePackageImport(specifier, parentPath) {
|
|
60
|
+
const key = `${path.dirname(parentPath)}\0${specifier}`
|
|
61
|
+
let cached = packageResolveCache.get(key)
|
|
62
|
+
if (!cached) {
|
|
63
|
+
try {
|
|
64
|
+
cached = { url: resolveImport(specifier, pathToFileURL(parentPath).href) }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
cached = { error: /** @type {Error} */ (error) }
|
|
67
|
+
}
|
|
68
|
+
packageResolveCache.set(key, cached)
|
|
69
|
+
}
|
|
70
|
+
if ('error' in cached) throw cached.error
|
|
71
|
+
return cached.url
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Drop memoized parses and resolutions (e.g. between runs in a long-lived process). */
|
|
75
|
+
function clearDepGraphCache() {
|
|
76
|
+
parseCache.clear()
|
|
77
|
+
relativeResolveCache.clear()
|
|
78
|
+
packageResolveCache.clear()
|
|
79
|
+
realpathCache.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
9
82
|
function fsExistsSync(path) {
|
|
10
83
|
try {
|
|
11
84
|
fs.accessSync(path, fs.constants.F_OK)
|
|
@@ -30,6 +103,21 @@ function isFileSync(filePath) {
|
|
|
30
103
|
* @returns {string|null} - Resolved file path or null if not found
|
|
31
104
|
*/
|
|
32
105
|
function resolveImportPath(baseDir, importPath) {
|
|
106
|
+
const key = `${baseDir}\0${importPath}`
|
|
107
|
+
let resolved = relativeResolveCache.get(key)
|
|
108
|
+
if (resolved === undefined) {
|
|
109
|
+
resolved = resolveImportPathUncached(baseDir, importPath)
|
|
110
|
+
relativeResolveCache.set(key, resolved)
|
|
111
|
+
}
|
|
112
|
+
return resolved
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} baseDir
|
|
117
|
+
* @param {string} importPath
|
|
118
|
+
* @returns {string|null}
|
|
119
|
+
*/
|
|
120
|
+
function resolveImportPathUncached(baseDir, importPath) {
|
|
33
121
|
const possibleExtensions = ['.js', '.mjs', '.ts', '.json', '']
|
|
34
122
|
|
|
35
123
|
// Try direct file resolution (must be a file, not directory)
|
|
@@ -198,7 +286,7 @@ function buildGraphRecursively(
|
|
|
198
286
|
visited = new Set(),
|
|
199
287
|
) {
|
|
200
288
|
// Avoid infinite loops
|
|
201
|
-
const normalizedPath =
|
|
289
|
+
const normalizedPath = realpath(filePath)
|
|
202
290
|
if (visited.has(normalizedPath)) {
|
|
203
291
|
return
|
|
204
292
|
}
|
|
@@ -210,8 +298,7 @@ function buildGraphRecursively(
|
|
|
210
298
|
}
|
|
211
299
|
|
|
212
300
|
// Read and extract deps from this file
|
|
213
|
-
const
|
|
214
|
-
const deps = extractDeps(content)
|
|
301
|
+
const deps = readDeps(filePath)
|
|
215
302
|
|
|
216
303
|
// Collect all relative dependencies
|
|
217
304
|
const relativeDeps = []
|
|
@@ -255,15 +342,14 @@ function buildGraphRecursively(
|
|
|
255
342
|
|
|
256
343
|
// Add edge to graph if building
|
|
257
344
|
if (graph !== null) {
|
|
258
|
-
const normalizedResolvedPath =
|
|
345
|
+
const normalizedResolvedPath = realpath(resolvedPath)
|
|
259
346
|
graph.get(normalizedPath).push(normalizedResolvedPath)
|
|
260
347
|
}
|
|
261
348
|
|
|
262
349
|
// Verify imports if verification is enabled
|
|
263
350
|
if (verify) {
|
|
264
351
|
// Extract deps from the imported file
|
|
265
|
-
const
|
|
266
|
-
const targetDeps = extractDeps(targetContent)
|
|
352
|
+
const targetDeps = readDeps(resolvedPath)
|
|
267
353
|
|
|
268
354
|
// Verify the imported values are exported
|
|
269
355
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
@@ -282,8 +368,6 @@ function buildGraphRecursively(
|
|
|
282
368
|
|
|
283
369
|
// Verify node_modules dependencies if enabled (skip built-ins)
|
|
284
370
|
if (verifyNodeModules && nodeModuleDeps.length > 0) {
|
|
285
|
-
const parentURL = pathToFileURL(filePath).href
|
|
286
|
-
|
|
287
371
|
for (const dep of nodeModuleDeps) {
|
|
288
372
|
const { from, line } = dep
|
|
289
373
|
|
|
@@ -295,7 +379,7 @@ function buildGraphRecursively(
|
|
|
295
379
|
let resolvedPath
|
|
296
380
|
try {
|
|
297
381
|
// resolveImport throws if file doesn't exist
|
|
298
|
-
const resolved =
|
|
382
|
+
const resolved = resolvePackageImport(from, filePath)
|
|
299
383
|
resolvedPath = fileURLToPath(resolved)
|
|
300
384
|
} catch (error) {
|
|
301
385
|
const err = new Error(
|
|
@@ -313,8 +397,7 @@ function buildGraphRecursively(
|
|
|
313
397
|
|
|
314
398
|
// Verify import/export styles match (only needed for ESM imports)
|
|
315
399
|
if (dep.isESM) {
|
|
316
|
-
const
|
|
317
|
-
const targetDeps = extractDeps(targetContent)
|
|
400
|
+
const targetDeps = readDeps(resolvedPath)
|
|
318
401
|
verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
|
|
319
402
|
}
|
|
320
403
|
}
|
|
@@ -470,5 +553,6 @@ if (require.main === module) {
|
|
|
470
553
|
|
|
471
554
|
module.exports = {
|
|
472
555
|
depGraph,
|
|
473
|
-
resolveDepPaths
|
|
556
|
+
resolveDepPaths,
|
|
557
|
+
clearDepGraphCache
|
|
474
558
|
}
|
package/src/get-cjs-exports.js
CHANGED
|
@@ -41,9 +41,11 @@ function getCjsExports(code) {
|
|
|
41
41
|
// values) keeps working off the same indices.
|
|
42
42
|
const sanitizedCode = stripCommentsAndStrings(code)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
44
|
+
// Their regexes need `module.exports` / `exports` in the sanitized text.
|
|
45
|
+
const hasModuleExports = sanitizedCode.includes('module.exports')
|
|
46
|
+
const assignmentExports = hasModuleExports ? getModuleExportsAssignment(code, sanitizedCode) : []
|
|
47
|
+
const modulePropertyExports = hasModuleExports ? getModuleExportsProperty(code, sanitizedCode) : []
|
|
48
|
+
const propertyExports = sanitizedCode.includes('exports') ? getExportsProperty(code, sanitizedCode) : []
|
|
47
49
|
|
|
48
50
|
// Quick pre-checks to avoid expensive regex operations when not needed
|
|
49
51
|
const objectAssignExports = sanitizedCode.includes('Object.assign')
|
package/src/get-exports.js
CHANGED
|
@@ -16,6 +16,8 @@ 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
|
+
// Every pattern below needs the word `export`; stripping never adds one.
|
|
20
|
+
if (!code.includes('export')) return []
|
|
19
21
|
// Comments first, then strings — order matters. Running stripStringContents
|
|
20
22
|
// alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
|
|
21
23
|
// with a later quote and shift subsequent backtick pairing, which could
|
package/src/get-imports.js
CHANGED
|
@@ -37,6 +37,8 @@ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)
|
|
|
37
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
|
|
38
38
|
|
|
39
39
|
function getImports(code = '', options = {}) {
|
|
40
|
+
// Every pattern below needs the word `import`; stripping never adds one.
|
|
41
|
+
if (!code.includes('import')) return []
|
|
40
42
|
// Strip comments FIRST so apostrophes/backticks inside JSDoc text get
|
|
41
43
|
// removed before `stripStringContents` looks for string literals.
|
|
42
44
|
// (Running the two in the opposite order let comment chars masquerade as
|
package/src/get-requires.js
CHANGED
|
@@ -105,6 +105,8 @@ function getStaticRequires(targetText = '') {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function getRequires(code) {
|
|
108
|
+
// Both scanners need the word `require`; stripping never adds one.
|
|
109
|
+
if (!code.includes('require')) return []
|
|
108
110
|
// Strip comments first, then strings — order matters. `stripComments`
|
|
109
111
|
// alone would mis-eat `//` inside strings; running it first instead means
|
|
110
112
|
// comment chars (incl. apostrophes/backticks inside JSDoc) disappear
|
package/src/utils.js
CHANGED
|
@@ -37,8 +37,18 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
|
|
|
37
37
|
function stripStringContents(code) {
|
|
38
38
|
let output = ''
|
|
39
39
|
let i = 0
|
|
40
|
+
const special = /[/"'`]/g
|
|
40
41
|
|
|
41
42
|
while (i < code.length) {
|
|
43
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
44
|
+
special.lastIndex = i
|
|
45
|
+
const next = special.exec(code)
|
|
46
|
+
const stop = next ? next.index : code.length
|
|
47
|
+
if (stop > i) {
|
|
48
|
+
output += code.slice(i, stop)
|
|
49
|
+
i = stop
|
|
50
|
+
if (i >= code.length) break
|
|
51
|
+
}
|
|
42
52
|
const char = code[i]
|
|
43
53
|
|
|
44
54
|
if (char === '/' && isRegexLiteralStart(code, i)) {
|
|
@@ -218,8 +228,18 @@ function appendSkippedComment(output, commentText, preserveLength) {
|
|
|
218
228
|
function stripCommentsImpl(code, { preserveLength = false } = {}) {
|
|
219
229
|
let output = ''
|
|
220
230
|
let i = 0
|
|
231
|
+
const special = /[/"'`]/g
|
|
221
232
|
|
|
222
233
|
while (i < code.length) {
|
|
234
|
+
// Copy the run of ordinary characters up to the next / or quote in one go.
|
|
235
|
+
special.lastIndex = i
|
|
236
|
+
const found = special.exec(code)
|
|
237
|
+
const stop = found ? found.index : code.length
|
|
238
|
+
if (stop > i) {
|
|
239
|
+
output += code.slice(i, stop)
|
|
240
|
+
i = stop
|
|
241
|
+
if (i >= code.length) break
|
|
242
|
+
}
|
|
223
243
|
const char = code[i]
|
|
224
244
|
const next = code[i + 1]
|
|
225
245
|
|
package/tests/dep-graph.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const { test } = require('uvu')
|
|
3
3
|
const assert = require('uvu/assert')
|
|
4
4
|
const path = require('path')
|
|
5
|
-
const { depGraph, resolveDepPaths } = require('../src/dep-graph.js')
|
|
5
|
+
const { depGraph, resolveDepPaths, clearDepGraphCache } = require('../src/dep-graph.js')
|
|
6
6
|
|
|
7
7
|
const CJS_FIXTURE_PATH = path.resolve(__dirname, './fixtures/cjs-example/index.js')
|
|
8
8
|
const ESM_FIXTURE_PATH = path.resolve(__dirname, './fixtures/esm-fixture/index.js')
|
|
@@ -727,4 +727,32 @@ test('TS regex exports: resolveDepPaths with returnErrors should have zero error
|
|
|
727
727
|
assert.is(result.errors.length, 0, 'Should have zero verification errors')
|
|
728
728
|
})
|
|
729
729
|
|
|
730
|
+
test('memoized walks see edited files and new files after clearDepGraphCache', () => {
|
|
731
|
+
const fs = require('fs')
|
|
732
|
+
const os = require('os')
|
|
733
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dep-graph-memo-')))
|
|
734
|
+
const entry = path.join(dir, 'index.js')
|
|
735
|
+
fs.writeFileSync(entry, "const a = require('./a')\nmodule.exports = a")
|
|
736
|
+
fs.writeFileSync(path.join(dir, 'a.js'), 'module.exports = 1')
|
|
737
|
+
try {
|
|
738
|
+
assert.equal(resolveDepPaths(entry).files.map(f => path.basename(f)), ['a.js', 'index.js'])
|
|
739
|
+
// Edit: a new import. mtime moves, so the file is parsed again.
|
|
740
|
+
fs.writeFileSync(path.join(dir, 'b.js'), 'module.exports = 2')
|
|
741
|
+
fs.writeFileSync(entry, "const a = require('./a')\nconst b = require('./b')\nmodule.exports = a + b")
|
|
742
|
+
const later = new Date(Date.now() + 5000)
|
|
743
|
+
fs.utimesSync(entry, later, later)
|
|
744
|
+
assert.equal(resolveDepPaths(entry).files.map(f => path.basename(f)).sort(), ['a.js', 'b.js', 'index.js'])
|
|
745
|
+
// A missing import stays missing until the cache is cleared.
|
|
746
|
+
fs.writeFileSync(entry, "const c = require('./c')\nmodule.exports = c")
|
|
747
|
+
const later2 = new Date(Date.now() + 10000)
|
|
748
|
+
fs.utimesSync(entry, later2, later2)
|
|
749
|
+
assert.is(resolveDepPaths(entry, { returnErrors: true }).errors.length, 1)
|
|
750
|
+
fs.writeFileSync(path.join(dir, 'c.js'), 'module.exports = 3')
|
|
751
|
+
clearDepGraphCache()
|
|
752
|
+
assert.is(resolveDepPaths(entry, { returnErrors: true }).errors.length, 0)
|
|
753
|
+
} finally {
|
|
754
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
|
|
730
758
|
test.run()
|