Skip to content

Commit 50d2403

Browse files
committed
feat: cache semver calls during dependency resolution
1 parent 5f18557 commit 50d2403

File tree

11 files changed

+464
-16
lines changed

11 files changed

+464
-16
lines changed

workspaces/arborist/lib/arborist/build-ideal-tree.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const { lstat, readlink } = require('node:fs/promises')
1313
const { depth } = require('treeverse')
1414
const { log, time } = require('proc-log')
1515
const { redact } = require('@npmcli/redact')
16-
const semver = require('semver')
16+
const semver = require('../cached-semver.js')
1717

1818
const {
1919
OK,

workspaces/arborist/lib/arborist/reify.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
const onExit = require('../signal-handling.js')
33
const pacote = require('pacote')
44
const AuditReport = require('../audit-report.js')
5-
const { subset, intersects } = require('semver')
5+
const semver = require('../cached-semver.js')
66
const npa = require('npm-package-arg')
7-
const semver = require('semver')
87
const debug = require('../debug.js')
98
const { walkUp } = require('walk-up-path')
109
const { log, time } = require('proc-log')
@@ -1390,7 +1389,7 @@ module.exports = cls => class Reifier extends cls {
13901389
if (
13911390
!isRange ||
13921391
spec === '*' ||
1393-
subset(prefixRange, spec, { loose: true })
1392+
semver.subset(prefixRange, spec, { loose: true })
13941393
) {
13951394
range = prefixRange
13961395
}
@@ -1445,11 +1444,11 @@ module.exports = cls => class Reifier extends cls {
14451444
if (hasSubKey(pkg, 'devDependencies', name)) {
14461445
pkg.devDependencies[name] = newSpec
14471446
// don't update peer or optional if we don't have to
1448-
if (hasSubKey(pkg, 'peerDependencies', name) && (isLocalDep || !intersects(newSpec, pkg.peerDependencies[name]))) {
1447+
if (hasSubKey(pkg, 'peerDependencies', name) && (isLocalDep || !semver.intersects(newSpec, pkg.peerDependencies[name]))) {
14491448
pkg.peerDependencies[name] = newSpec
14501449
}
14511450

1452-
if (hasSubKey(pkg, 'optionalDependencies', name) && (isLocalDep || !intersects(newSpec, pkg.optionalDependencies[name]))) {
1451+
if (hasSubKey(pkg, 'optionalDependencies', name) && (isLocalDep || !semver.intersects(newSpec, pkg.optionalDependencies[name]))) {
14531452
pkg.optionalDependencies[name] = newSpec
14541453
}
14551454
} else {
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
const semver = require('semver')
2+
3+
const MAX_CACHE_SIZE = 1000
4+
5+
// Simple LRU cache implementation using Map's insertion order
6+
class LRUCache {
7+
constructor (maxSize) {
8+
this.maxSize = maxSize
9+
this.cache = new Map()
10+
}
11+
12+
get (key) {
13+
if (this.cache.has(key)) {
14+
// Move to end (most recently used)
15+
const value = this.cache.get(key)
16+
this.cache.delete(key)
17+
this.cache.set(key, value)
18+
return value
19+
}
20+
return undefined
21+
}
22+
23+
set (key, value) {
24+
if (this.cache.has(key)) {
25+
// Update existing - move to end
26+
/* istanbul ignore next - cache update path not reachable with current implementation */
27+
this.cache.delete(key)
28+
} else if (this.cache.size >= this.maxSize) {
29+
// Evict least recently used (first entry)
30+
const firstKey = this.cache.keys().next().value
31+
this.cache.delete(firstKey)
32+
}
33+
this.cache.set(key, value)
34+
}
35+
36+
/* istanbul ignore next - method never called by current implementation */
37+
has (key) {
38+
return this.cache.has(key)
39+
}
40+
41+
/* istanbul ignore next - method never called by current implementation */
42+
get size () {
43+
return this.cache.size
44+
}
45+
}
46+
47+
const versionCache = new LRUCache(MAX_CACHE_SIZE)
48+
const rangeCache = new LRUCache(MAX_CACHE_SIZE)
49+
const versionCacheClean = new LRUCache(MAX_CACHE_SIZE)
50+
51+
function createCacheKey (input, options = {}) {
52+
const keys = Object.keys(options)
53+
return keys.length === 0
54+
? input
55+
: `${input}|${JSON.stringify(options, keys.sort())}`
56+
}
57+
58+
function parseVersion (version) {
59+
if (typeof version !== 'string') {
60+
return null
61+
}
62+
63+
const cached = versionCache.get(version)
64+
if (cached !== undefined) {
65+
return cached
66+
}
67+
68+
const parsed = semver.parse(version)
69+
versionCache.set(version, parsed)
70+
71+
return parsed
72+
}
73+
74+
function parseRange (range, options = {}) {
75+
/* istanbul ignore next - non-string inputs filtered at higher levels */
76+
if (typeof range !== 'string') {
77+
return null
78+
}
79+
80+
const cacheKey = createCacheKey(range, options)
81+
82+
const cached = rangeCache.get(cacheKey)
83+
if (cached !== undefined) {
84+
return cached
85+
}
86+
87+
let parsed
88+
try {
89+
parsed = new semver.Range(range, options)
90+
} catch (err) {
91+
parsed = null
92+
}
93+
94+
rangeCache.set(cacheKey, parsed)
95+
96+
return parsed
97+
}
98+
99+
function satisfies (version, range, options = {}) {
100+
// For any non-string inputs, delegate to original semver to maintain exact behavior
101+
if (typeof version !== 'string' || typeof range !== 'string') {
102+
return semver.satisfies(version, range, options)
103+
}
104+
105+
if (range === version) {
106+
return true
107+
}
108+
109+
const parsedVersion = parseVersion(version)
110+
if (!parsedVersion) {
111+
return false
112+
}
113+
114+
const parsedRange = parseRange(range, options)
115+
if (!parsedRange) {
116+
return false
117+
}
118+
119+
try {
120+
return parsedRange.test(parsedVersion)
121+
} catch (err) {
122+
/* istanbul ignore next - defensive programming, semver rarely throws */
123+
return false
124+
}
125+
}
126+
127+
function intersects (range1, range2, options = {}) {
128+
// For any non-string inputs, delegate to original semver to maintain exact behavior
129+
if (typeof range1 !== 'string' || typeof range2 !== 'string') {
130+
return semver.intersects(range1, range2, options)
131+
}
132+
133+
if (range1 === range2) {
134+
return true
135+
}
136+
if (range1 === '*' || range2 === '*') {
137+
return true
138+
}
139+
140+
const parsedRange1 = parseRange(range1, options)
141+
const parsedRange2 = parseRange(range2, options)
142+
143+
if (!parsedRange1 || !parsedRange2) {
144+
return false
145+
}
146+
147+
try {
148+
return parsedRange1.intersects(parsedRange2)
149+
} catch (err) {
150+
/* istanbul ignore next - defensive programming, semver rarely throws */
151+
return false
152+
}
153+
}
154+
155+
function valid (version) {
156+
if (typeof version !== 'string') {
157+
return null
158+
}
159+
160+
const parsed = parseVersion(version)
161+
return parsed ? parsed.version : null
162+
}
163+
164+
function validRange (range, options = {}) {
165+
if (typeof range !== 'string') {
166+
return null
167+
}
168+
169+
const parsed = parseRange(range, options)
170+
if (!parsed) {
171+
return null
172+
}
173+
174+
// Handle special case: semver.validRange("*") returns "*", not ""
175+
if (range === '*' || range === '') {
176+
return '*'
177+
}
178+
179+
return parsed.range
180+
}
181+
182+
function clean (version, options = {}) {
183+
if (typeof version !== 'string') {
184+
return null
185+
}
186+
187+
const cacheKey = createCacheKey(version, options)
188+
189+
const cached = versionCacheClean.get(cacheKey)
190+
if (cached !== undefined) {
191+
return cached
192+
}
193+
194+
const result = semver.clean(version, options)
195+
versionCacheClean.set(cacheKey, result)
196+
197+
return result
198+
}
199+
200+
module.exports = {
201+
// Start with all semver functions
202+
...semver,
203+
204+
// Override with cached implementations
205+
satisfies,
206+
intersects,
207+
valid,
208+
validRange,
209+
clean,
210+
parse: parseVersion,
211+
}

workspaces/arborist/lib/can-place-dep.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
// the replaced node for resolution elsewhere.
3737

3838
const localeCompare = require('@isaacs/string-locale-compare')('en')
39-
const semver = require('semver')
39+
const semver = require('./cached-semver.js')
4040
const debug = require('./debug.js')
4141
const peerEntrySets = require('./peer-entry-sets.js')
4242
const deepestNestingTarget = require('./deepest-nesting-target.js')

workspaces/arborist/lib/dep-valid.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// client-specific package.json meta _fields, but most of
55
// the time will be pulled out of a lockfile
66

7-
const semver = require('semver')
7+
const semver = require('./cached-semver.js')
88
const npa = require('npm-package-arg')
99
const { relative } = require('node:path')
1010
const fromPath = require('./from-path.js')

workspaces/arborist/lib/node.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
// where we need to quickly find all instances of a given package name within a
2929
// tree.
3030

31-
const semver = require('semver')
31+
const semver = require('./cached-semver.js')
3232
const nameFromFolder = require('@npmcli/name-from-folder')
3333
const Edge = require('./edge.js')
3434
const Inventory = require('./inventory.js')

workspaces/arborist/lib/override-set.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const npa = require('npm-package-arg')
2-
const semver = require('semver')
2+
const semver = require('./cached-semver.js')
33
const { log } = require('proc-log')
44

55
class OverrideSet {

workspaces/arborist/lib/query-selector-all.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { log } = require('proc-log')
77
const { minimatch } = require('minimatch')
88
const npa = require('npm-package-arg')
99
const pacote = require('pacote')
10-
const semver = require('semver')
10+
const semver = require('./cached-semver.js')
1111
const npmFetch = require('npm-registry-fetch')
1212

1313
// handle results for parsed query asts, results are stored in a map that has a
@@ -366,7 +366,7 @@ class Results {
366366
// two ranges -> semver.intersects
367367
actualFunc = 'intersects'
368368
} else {
369-
// anything else -> semver.satisfies
369+
// anything else -> satisfies
370370
actualFunc = 'satisfies'
371371
}
372372
}

workspaces/arborist/lib/version-from-tgz.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const semver = require('semver')
1+
const semver = require('./cached-semver.js')
22
const { basename } = require('node:path')
33
const { URL } = require('node:url')
44
module.exports = (name, tgz) => {

workspaces/arborist/lib/vuln.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// @npmcli/metavuln-calculator
1212
// - via: dependency vulns which cause this one
1313

14-
const { satisfies, simplifyRange } = require('semver')
14+
const semver = require('./cached-semver.js')
1515
const semverOpt = { loose: true, includePrerelease: true }
1616

1717
const localeCompare = require('@isaacs/string-locale-compare')('en')
@@ -101,7 +101,7 @@ class Vuln {
101101
}
102102

103103
for (const v of this.versions) {
104-
if (satisfies(v, spec) && !satisfies(v, this.range, semverOpt)) {
104+
if (semver.satisfies(v, spec) && !semver.satisfies(v, this.range, semverOpt)) {
105105
return false
106106
}
107107
}
@@ -185,7 +185,7 @@ class Vuln {
185185

186186
const versions = [...this.advisories][0].versions
187187
const range = this.range
188-
this.#simpleRange = simplifyRange(versions, range, semverOpt)
188+
this.#simpleRange = semver.simplifyRange(versions, range, semverOpt)
189189
this.#range = this.#simpleRange
190190
return this.#simpleRange
191191
}

0 commit comments

Comments
 (0)