@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidwells/extract-deps",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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
@@ -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 = fs.realpathSync(filePath)
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 content = fs.readFileSync(filePath, 'utf8')
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 = fs.realpathSync(resolvedPath)
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 targetContent = fs.readFileSync(resolvedPath, 'utf8')
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 = resolveImport(from, parentURL)
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 targetContent = fs.readFileSync(resolvedPath, 'utf8')
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
  }
@@ -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
- const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
45
- const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
46
- const propertyExports = getExportsProperty(code, sanitizedCode)
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')
@@ -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
@@ -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
@@ -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
 
@@ -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()