Skip to content

Commit ebf167b

Browse files
nlflukekarrys
authored andcommitted
feat: add :outdated pseudo selector
1 parent d030f10 commit ebf167b

File tree

5 files changed

+598
-22
lines changed

5 files changed

+598
-22
lines changed

workspaces/arborist/lib/node.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ class Node {
103103
dummy = false,
104104
sourceReference = null,
105105
} = options
106+
// this object gives querySelectorAll somewhere to stash context about a node
107+
// while processing a query
108+
this.queryContext = {}
106109

107110
// true if part of a global install
108111
this[_global] = global
@@ -1455,8 +1458,8 @@ class Node {
14551458

14561459
// maybe accept both string value or array of strings
14571460
// seems to be what dom API does
1458-
querySelectorAll (query) {
1459-
return querySelectorAll(this, query)
1461+
querySelectorAll (query, opts) {
1462+
return querySelectorAll(this, query, opts)
14601463
}
14611464

14621465
toJSON () {

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

+193-14
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const localeCompare = require('@isaacs/string-locale-compare')('en')
66
const log = require('proc-log')
77
const minimatch = require('minimatch')
88
const npa = require('npm-package-arg')
9+
const pacote = require('pacote')
910
const semver = require('semver')
1011

1112
// handle results for parsed query asts, results are stored in a map that has a
@@ -16,6 +17,7 @@ class Results {
1617
#currentAstSelector
1718
#initialItems
1819
#inventory
20+
#outdatedCache = new Map()
1921
#pendingCombinator
2022
#results = new Map()
2123
#targetNode
@@ -28,6 +30,9 @@ class Results {
2830

2931
this.currentResults = this.#initialItems
3032

33+
// We get this when first called and need to pass it to pacote
34+
this.flatOptions = opts.flatOptions || {}
35+
3136
// reset by rootAstNode walker
3237
this.currentAstNode = opts.rootAstNode
3338
}
@@ -58,6 +63,7 @@ class Results {
5863
if (firstParsed) {
5964
return this.#initialItems
6065
}
66+
6167
if (this.currentAstNode.prev().type === 'combinator') {
6268
return this.#inventory
6369
}
@@ -125,7 +131,7 @@ class Results {
125131
}
126132

127133
// pseudo selectors (prefixed with :)
128-
pseudoType () {
134+
async pseudoType () {
129135
const pseudoFn = `${this.currentAstNode.value.slice(1)}Pseudo`
130136
if (!this[pseudoFn]) {
131137
throw Object.assign(
@@ -134,7 +140,7 @@ class Results {
134140
{ code: 'EQUERYNOPSEUDO' }
135141
)
136142
}
137-
const nextResults = this[pseudoFn]()
143+
const nextResults = await this[pseudoFn]()
138144
this.processPendingCombinator(nextResults)
139145
}
140146

@@ -195,11 +201,12 @@ class Results {
195201
return this.initialItems.filter(node => node.extraneous)
196202
}
197203

198-
hasPseudo () {
204+
async hasPseudo () {
199205
const found = []
200206
for (const item of this.initialItems) {
201-
const res = retrieveNodesFromParsedAst({
202-
// This is the one time initialItems differs from inventory
207+
// This is the one time initialItems differs from inventory
208+
const res = await retrieveNodesFromParsedAst({
209+
flatOptions: this.flatOptions,
203210
initialItems: [item],
204211
inventory: this.#inventory,
205212
rootAstNode: this.currentAstNode.nestedNode,
@@ -225,8 +232,9 @@ class Results {
225232
return found
226233
}
227234

228-
isPseudo () {
229-
const res = retrieveNodesFromParsedAst({
235+
async isPseudo () {
236+
const res = await retrieveNodesFromParsedAst({
237+
flatOptions: this.flatOptions,
230238
initialItems: this.initialItems,
231239
inventory: this.#inventory,
232240
rootAstNode: this.currentAstNode.nestedNode,
@@ -251,8 +259,9 @@ class Results {
251259
}, [])
252260
}
253261

254-
notPseudo () {
255-
const res = retrieveNodesFromParsedAst({
262+
async notPseudo () {
263+
const res = await retrieveNodesFromParsedAst({
264+
flatOptions: this.flatOptions,
256265
initialItems: this.initialItems,
257266
inventory: this.#inventory,
258267
rootAstNode: this.currentAstNode.nestedNode,
@@ -422,6 +431,135 @@ class Results {
422431
dedupedPseudo () {
423432
return this.initialItems.filter(node => node.target.edgesIn.size > 1)
424433
}
434+
435+
async outdatedPseudo () {
436+
const { outdatedKind = 'any' } = this.currentAstNode
437+
438+
// filter the initialItems
439+
// NOTE: this uses a Promise.all around a map without in-line concurrency handling
440+
// since the only async action taken is retrieving the packument, which is limited
441+
// based on the max-sockets config in make-fetch-happen
442+
const initialResults = await Promise.all(this.initialItems.map(async (node) => {
443+
// the root can't be outdated, skip it
444+
if (node.isProjectRoot) {
445+
return false
446+
}
447+
448+
// we cache the promise representing the full versions list, this helps reduce the
449+
// number of requests we send by keeping population of the cache in a single tick
450+
// making it less likely that multiple requests for the same package will be inflight
451+
if (!this.#outdatedCache.has(node.name)) {
452+
this.#outdatedCache.set(node.name, getPackageVersions(node.name, this.flatOptions))
453+
}
454+
const availableVersions = await this.#outdatedCache.get(node.name)
455+
456+
// we attach _all_ versions to the queryContext to allow consumers to do their own
457+
// filtering and comparisons
458+
node.queryContext.versions = availableVersions
459+
460+
// next we further reduce the set to versions that are greater than the current one
461+
const greaterVersions = availableVersions.filter((available) => {
462+
return semver.gt(available, node.version)
463+
})
464+
465+
// no newer versions than the current one, drop this node from the result set
466+
if (!greaterVersions.length) {
467+
return false
468+
}
469+
470+
// if we got here, we know that newer versions exist, if the kind is 'any' we're done
471+
if (outdatedKind === 'any') {
472+
return node
473+
}
474+
475+
// look for newer versions that differ from current by a specific part of the semver version
476+
if (['major', 'minor', 'patch'].includes(outdatedKind)) {
477+
// filter the versions greater than our current one based on semver.diff
478+
const filteredVersions = greaterVersions.filter((version) => {
479+
return semver.diff(node.version, version) === outdatedKind
480+
})
481+
482+
// no available versions are of the correct diff type
483+
if (!filteredVersions.length) {
484+
return false
485+
}
486+
487+
return node
488+
}
489+
490+
// look for newer versions that satisfy at least one edgeIn to this node
491+
if (outdatedKind === 'in-range') {
492+
const inRangeContext = []
493+
for (const edge of node.edgesIn) {
494+
const inRangeVersions = greaterVersions.filter((version) => {
495+
return semver.satisfies(version, edge.spec)
496+
})
497+
498+
// this edge has no in-range candidates, just move on
499+
if (!inRangeVersions.length) {
500+
continue
501+
}
502+
503+
inRangeContext.push({
504+
from: edge.from.location,
505+
versions: inRangeVersions,
506+
})
507+
}
508+
509+
// if we didn't find at least one match, drop this node
510+
if (!inRangeContext.length) {
511+
return false
512+
}
513+
514+
// now add to the context each version that is in-range for each edgeIn
515+
node.queryContext.outdated = {
516+
...node.queryContext.outdated,
517+
inRange: inRangeContext,
518+
}
519+
520+
return node
521+
}
522+
523+
// look for newer versions that _do not_ satisfy at least one edgeIn
524+
if (outdatedKind === 'out-of-range') {
525+
const outOfRangeContext = []
526+
for (const edge of node.edgesIn) {
527+
const outOfRangeVersions = greaterVersions.filter((version) => {
528+
return !semver.satisfies(version, edge.spec)
529+
})
530+
531+
// this edge has no out-of-range candidates, skip it
532+
if (!outOfRangeVersions.length) {
533+
continue
534+
}
535+
536+
outOfRangeContext.push({
537+
from: edge.from.location,
538+
versions: outOfRangeVersions,
539+
})
540+
}
541+
542+
// if we didn't add at least one thing to the context, this node is not a match
543+
if (!outOfRangeContext.length) {
544+
return false
545+
}
546+
547+
// attach the out-of-range context to the node
548+
node.queryContext.outdated = {
549+
...node.queryContext.outdated,
550+
outOfRange: outOfRangeContext,
551+
}
552+
553+
return node
554+
}
555+
556+
// any other outdatedKind is unknown and will never match
557+
return false
558+
}))
559+
560+
// return an array with the holes for non-matching nodes removed
561+
return initialResults.filter(Boolean)
562+
}
425563
}
426564

427565
// operators for attribute selectors
@@ -622,7 +760,41 @@ const combinators = {
622760
},
623761
}
624762

625-
const retrieveNodesFromParsedAst = (opts) => {
763+
// get a list of available versions of a package filtered to respect --before
764+
// NOTE: this runs over each node and should not throw
765+
const getPackageVersions = async (name, opts) => {
766+
let packument
767+
try {
768+
packument = await pacote.packument(name, {
769+
...opts,
770+
fullMetadata: false, // we only need the corgi
771+
})
772+
} catch (err) {
773+
// if the fetch fails, log a warning and pretend there are no versions
774+
log.warn('query', `could not retrieve packument for ${name}: ${err.message}`)
775+
return []
776+
}
777+
778+
// start with a sorted list of all versions (lowest first)
779+
let candidates = Object.keys(packument.versions).sort(semver.compare)
780+
781+
// if the packument has a time property, and the user passed a before flag, then
782+
// we filter this list down to only those versions that existed before the specified date
783+
if (packument.time && opts.before) {
784+
candidates = candidates.filter((version) => {
785+
// this version isn't found in the times at all, drop it
786+
if (!packument.time[version]) {
787+
return false
788+
}
789+
790+
return Date.parse(packument.time[version]) <= opts.before
791+
})
792+
}
793+
794+
return candidates
795+
}
796+
797+
const retrieveNodesFromParsedAst = async (opts) => {
626798
// when we first call this it's the parsed query. all other times it's
627799
// results.currentNode.nestedNode
628800
const rootAstNode = opts.rootAstNode
@@ -633,7 +805,13 @@ const retrieveNodesFromParsedAst = (opts) => {
633805

634806
const results = new Results(opts)
635807

808+
const astNodeQueue = new Set()
809+
// walk is sync, so we have to build up our async functions and then await them later
636810
rootAstNode.walk((nextAstNode) => {
811+
astNodeQueue.add(nextAstNode)
812+
})
813+
814+
for (const nextAstNode of astNodeQueue) {
637815
// This is the only place we reset currentAstNode
638816
results.currentAstNode = nextAstNode
639817
const updateFn = `${results.currentAstNode.type}Type`
@@ -643,23 +821,24 @@ const retrieveNodesFromParsedAst = (opts) => {
643821
{ code: 'EQUERYNOSELECTOR' }
644822
)
645823
}
646-
results[updateFn]()
647-
})
824+
await results[updateFn]()
825+
}
648826

649827
return results.collect(rootAstNode)
650828
}
651829

652830
// We are keeping this async in the event that we do add async operators, we
653831
// won't have to have a breaking change on this function signature.
654-
const querySelectorAll = async (targetNode, query) => {
832+
const querySelectorAll = async (targetNode, query, flatOptions) => {
655833
// This never changes ever we just pass it around. But we can't scope it to
656834
// this whole file if we ever want to support concurrent calls to this
657835
// function.
658836
const inventory = [...targetNode.root.inventory.values()]
659837
// res is a Set of items returned for each parsed css ast selector
660-
const res = retrieveNodesFromParsedAst({
838+
const res = await retrieveNodesFromParsedAst({
661839
initialItems: inventory,
662840
inventory,
841+
flatOptions,
663842
rootAstNode: parser(query),
664843
targetNode,
665844
})

workspaces/arborist/tap-snapshots/test/link.js.test.cjs

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Link {
2626
"optional": true,
2727
"path": "/home/user/some/other/path",
2828
"peer": true,
29+
"queryContext": Object {},
2930
"realpath": "/home/user/projects/some/kind/of/path",
3031
"sourceReference": null,
3132
"tops": Set {},
@@ -70,6 +71,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
7071
"optional": true,
7172
"path": "/home/user/projects/some/kind/of/path",
7273
"peer": true,
74+
"queryContext": Object {},
7375
"realpath": "/home/user/projects/some/kind/of/path",
7476
"resolved": null,
7577
"sourceReference": null,
@@ -83,6 +85,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
8385
"optional": true,
8486
"path": "/home/user/some/other/path",
8587
"peer": true,
88+
"queryContext": Object {},
8689
"realpath": "/home/user/projects/some/kind/of/path",
8790
"sourceReference": null,
8891
"tops": Set {
@@ -109,6 +112,7 @@ exports[`test/link.js TAP > instantiate without providing target 1`] = `
109112
"optional": true,
110113
"path": "/home/user/projects/some/kind/of/path",
111114
"peer": true,
115+
"queryContext": Object {},
112116
"realpath": "/home/user/projects/some/kind/of/path",
113117
"resolved": null,
114118
"sourceReference": null,

0 commit comments

Comments
 (0)