Skip to content

Commit 833cf7d

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

File tree

11 files changed

+471
-16
lines changed

11 files changed

+471
-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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
/* istanbul ignore if - cache update path not reachable with current implementation */
25+
if (this.cache.has(key)) {
26+
// Update existing - move to end
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+
/* istanbul ignore next */
56+
: `${input}|${JSON.stringify(options, keys.sort())}`
57+
}
58+
59+
function parseVersion (version) {
60+
if (typeof version !== 'string') {
61+
return null
62+
}
63+
64+
const cached = versionCache.get(version)
65+
if (cached !== undefined) {
66+
return cached
67+
}
68+
69+
const parsed = semver.parse(version)
70+
versionCache.set(version, parsed)
71+
72+
return parsed
73+
}
74+
75+
function parseRange (range, options = {}) {
76+
if (typeof range !== 'string') {
77+
/* istanbul ignore next */
78+
return null
79+
}
80+
81+
const cacheKey = createCacheKey(range, options)
82+
83+
const cached = rangeCache.get(cacheKey)
84+
if (cached !== undefined) {
85+
return cached
86+
}
87+
88+
let parsed
89+
try {
90+
parsed = new semver.Range(range, options)
91+
} catch (err) {
92+
parsed = null
93+
}
94+
95+
rangeCache.set(cacheKey, parsed)
96+
97+
return parsed
98+
}
99+
100+
function satisfies (version, range, options = {}) {
101+
// For any non-string inputs, delegate to original semver to maintain exact behavior
102+
if (typeof version !== 'string' || typeof range !== 'string') {
103+
return semver.satisfies(version, range, options)
104+
}
105+
106+
if (range === version) {
107+
return true
108+
}
109+
110+
const parsedVersion = parseVersion(version)
111+
if (!parsedVersion) {
112+
return false
113+
}
114+
115+
const parsedRange = parseRange(range, options)
116+
if (!parsedRange) {
117+
return false
118+
}
119+
120+
try {
121+
return parsedRange.test(parsedVersion)
122+
} catch (err) {
123+
/* istanbul ignore next - defensive programming, semver rarely throws */
124+
return false
125+
}
126+
}
127+
128+
function intersects (range1, range2, options = {}) {
129+
// For any non-string inputs, delegate to original semver to maintain exact behavior
130+
if (typeof range1 !== 'string' || typeof range2 !== 'string') {
131+
return semver.intersects(range1, range2, options)
132+
}
133+
134+
if (range1 === range2) {
135+
return true
136+
}
137+
if (range1 === '*' || range2 === '*') {
138+
return true
139+
}
140+
141+
const parsedRange1 = parseRange(range1, options)
142+
const parsedRange2 = parseRange(range2, options)
143+
144+
if (!parsedRange1 || !parsedRange2) {
145+
return false
146+
}
147+
148+
try {
149+
return parsedRange1.intersects(parsedRange2)
150+
} catch (err) {
151+
/* istanbul ignore next - defensive programming, semver rarely throws */
152+
return false
153+
}
154+
}
155+
156+
function valid (version) {
157+
if (typeof version !== 'string') {
158+
return null
159+
}
160+
161+
const parsed = parseVersion(version)
162+
return parsed ? parsed.version : null
163+
}
164+
165+
function validRange (range, options = {}) {
166+
if (typeof range !== 'string') {
167+
return null
168+
}
169+
170+
const parsed = parseRange(range, options)
171+
if (!parsed) {
172+
return null
173+
}
174+
175+
// Handle special case: semver.validRange("*") returns "*", not ""
176+
if (range === '*' || range === '') {
177+
return '*'
178+
}
179+
180+
return parsed.range
181+
}
182+
183+
function clean (version, options = {}) {
184+
if (typeof version !== 'string') {
185+
return null
186+
}
187+
188+
const cacheKey = createCacheKey(version, options)
189+
190+
const cached = versionCacheClean.get(cacheKey)
191+
if (cached !== undefined) {
192+
return cached
193+
}
194+
195+
const result = semver.clean(version, options)
196+
versionCacheClean.set(cacheKey, result)
197+
198+
return result
199+
}
200+
201+
module.exports = {
202+
// Start with all semver functions
203+
...semver,
204+
205+
// Override with cached implementations
206+
satisfies,
207+
intersects,
208+
valid,
209+
validRange,
210+
clean,
211+
parse: parseVersion,
212+
}

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)