관리-도구
편집 파일: query-selector-all.js
'use strict' const { resolve } = require('path') const { parser, arrayDelimiter } = require('@npmcli/query') const localeCompare = require('@isaacs/string-locale-compare')('en') const { log } = require('proc-log') const { minimatch } = require('minimatch') const npa = require('npm-package-arg') const pacote = require('pacote') const semver = require('semver') const fetch = require('npm-registry-fetch') // handle results for parsed query asts, results are stored in a map that has a // key that points to each ast selector node and stores the resulting array of // arborist nodes as its value, that is essential to how we handle multiple // query selectors, e.g: `#a, #b, #c` <- 3 diff ast selector nodes class Results { #currentAstSelector #initialItems #inventory #outdatedCache = new Map() #vulnCache #pendingCombinator #results = new Map() #targetNode constructor (opts) { this.#currentAstSelector = opts.rootAstNode.nodes[0] this.#inventory = opts.inventory this.#initialItems = opts.initialItems this.#vulnCache = opts.vulnCache this.#targetNode = opts.targetNode this.currentResults = this.#initialItems // We get this when first called and need to pass it to pacote this.flatOptions = opts.flatOptions || {} // reset by rootAstNode walker this.currentAstNode = opts.rootAstNode } get currentResults () { return this.#results.get(this.#currentAstSelector) } set currentResults (value) { this.#results.set(this.#currentAstSelector, value) } // retrieves the initial items to which start the filtering / matching // for most of the different types of recognized ast nodes, e.g: class (aka // depType), id, *, etc in different contexts we need to start with the // current list of filtered results, for example a query for `.workspace` // actually means the same as `*.workspace` so we want to start with the full // inventory if that's the first ast node we're reading but if it appears in // the middle of a query it should respect the previous filtered results, // combinators are a special case in which we always want to have the // complete inventory list in order to use the left-hand side ast node as a // filter combined with the element on its right-hand side get initialItems () { const firstParsed = (this.currentAstNode.parent.nodes[0] === this.currentAstNode) && (this.currentAstNode.parent.parent.type === 'root') if (firstParsed) { return this.#initialItems } if (this.currentAstNode.prev().type === 'combinator') { return this.#inventory } return this.currentResults } // combinators need information about previously filtered items along // with info of the items parsed / retrieved from the selector right // past the combinator, for this reason combinators are stored and // only ran as the last part of each selector logic processPendingCombinator (nextResults) { if (this.#pendingCombinator) { const res = this.#pendingCombinator(this.currentResults, nextResults) this.#pendingCombinator = null this.currentResults = res } else { this.currentResults = nextResults } } // when collecting results to a root astNode, we traverse the list of child // selector nodes and collect all of their resulting arborist nodes into a // single/flat Set of items, this ensures we also deduplicate items collect (rootAstNode) { return new Set(rootAstNode.nodes.flatMap(n => this.#results.get(n))) } // selector types map to the '.type' property of the ast nodes via `${astNode.type}Type` // // attribute selector [name=value], etc attributeType () { const nextResults = this.initialItems.filter(node => attributeMatch(this.currentAstNode, node.package) ) this.processPendingCombinator(nextResults) } // dependency type selector (i.e. .prod, .dev, etc) // css calls this class, we interpret is as dependency type classType () { const depTypeFn = depTypes[String(this.currentAstNode)] if (!depTypeFn) { throw Object.assign( new Error(`\`${String(this.currentAstNode)}\` is not a supported dependency type.`), { code: 'EQUERYNODEPTYPE' } ) } const nextResults = depTypeFn(this.initialItems) this.processPendingCombinator(nextResults) } // combinators (i.e. '>', ' ', '~') combinatorType () { this.#pendingCombinator = combinators[String(this.currentAstNode)] } // name selectors (i.e. #foo) // css calls this id, we interpret it as name idType () { const name = this.currentAstNode.value const nextResults = this.initialItems.filter(node => (name === node.name) || (name === node.package.name) ) this.processPendingCombinator(nextResults) } // pseudo selectors (prefixed with :) async pseudoType () { const pseudoFn = `${this.currentAstNode.value.slice(1)}Pseudo` if (!this[pseudoFn]) { throw Object.assign( new Error(`\`${this.currentAstNode.value }\` is not a supported pseudo selector.`), { code: 'EQUERYNOPSEUDO' } ) } const nextResults = await this[pseudoFn]() this.processPendingCombinator(nextResults) } selectorType () { this.#currentAstSelector = this.currentAstNode // starts a new array in which resulting items // can be stored for each given ast selector if (!this.currentResults) { this.currentResults = [] } } universalType () { this.processPendingCombinator(this.initialItems) } // pseudo selectors map to the 'value' property of the pseudo selectors in the ast nodes // via selectors via `${value.slice(1)}Pseudo` attrPseudo () { const { lookupProperties, attributeMatcher } = this.currentAstNode return this.initialItems.filter(node => { let objs = [node.package] for (const prop of lookupProperties) { // if an isArray symbol is found that means we'll need to iterate // over the previous found array to basically make sure we traverse // all its indexes testing for possible objects that may eventually // hold more keys specified in a selector if (prop === arrayDelimiter) { objs = objs.flat() continue } // otherwise just maps all currently found objs // to the next prop from the lookup properties list, // filters out any empty key lookup objs = objs.flatMap(obj => obj[prop] || []) // in case there's no property found in the lookup // just filters that item out const noAttr = objs.every(obj => !obj) if (noAttr) { return false } } // if any of the potential object matches // that item should be in the final result return objs.some(obj => attributeMatch(attributeMatcher, obj)) }) } emptyPseudo () { return this.initialItems.filter(node => node.edgesOut.size === 0) } extraneousPseudo () { return this.initialItems.filter(node => node.extraneous) } async hasPseudo () { const found = [] for (const item of this.initialItems) { // This is the one time initialItems differs from inventory const res = await retrieveNodesFromParsedAst({ flatOptions: this.flatOptions, initialItems: [item], inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: item, vulnCache: this.#vulnCache, }) if (res.size > 0) { found.push(item) } } return found } invalidPseudo () { const found = [] for (const node of this.initialItems) { for (const edge of node.edgesIn) { if (edge.invalid) { found.push(node) break } } } return found } async isPseudo () { const res = await retrieveNodesFromParsedAst({ flatOptions: this.flatOptions, initialItems: this.initialItems, inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: this.currentAstNode, vulnCache: this.#vulnCache, }) return [...res] } linkPseudo () { return this.initialItems.filter(node => node.isLink || (node.isTop && !node.isRoot)) } missingPseudo () { return this.#inventory.reduce((res, node) => { for (const edge of node.edgesOut.values()) { if (edge.missing) { const pkg = { name: edge.name, version: edge.spec } const item = new this.#targetNode.constructor({ pkg }) item.queryContext = { missing: true, } item.edgesIn = new Set([edge]) res.push(item) } } return res }, []) } async notPseudo () { const res = await retrieveNodesFromParsedAst({ flatOptions: this.flatOptions, initialItems: this.initialItems, inventory: this.#inventory, rootAstNode: this.currentAstNode.nestedNode, targetNode: this.currentAstNode, vulnCache: this.#vulnCache, }) const internalSelector = new Set(res) return this.initialItems.filter(node => !internalSelector.has(node)) } overriddenPseudo () { return this.initialItems.filter(node => node.overridden) } pathPseudo () { return this.initialItems.filter(node => { if (!this.currentAstNode.pathValue) { return true } return minimatch( node.realpath.replace(/\\+/g, '/'), resolve(node.root.realpath, this.currentAstNode.pathValue).replace(/\\+/g, '/') ) }) } privatePseudo () { return this.initialItems.filter(node => node.package.private) } rootPseudo () { return this.initialItems.filter(node => node === this.#targetNode.root) } scopePseudo () { return this.initialItems.filter(node => node === this.#targetNode) } semverPseudo () { const { attributeMatcher, lookupProperties, semverFunc = 'infer', semverValue, } = this.currentAstNode const { qualifiedAttribute } = attributeMatcher if (!semverValue) { // DEPRECATED: remove this warning and throw an error as part of @npmcli/arborist@6 log.warn('query', 'usage of :semver() with no parameters is deprecated') return this.initialItems } if (!semver.valid(semverValue) && !semver.validRange(semverValue)) { throw Object.assign( new Error(`\`${semverValue}\` is not a valid semver version or range`), { code: 'EQUERYINVALIDSEMVER' }) } const valueIsVersion = !!semver.valid(semverValue) const nodeMatches = (node, obj) => { // if we already have an operator, the user provided some test as part of the selector // we evaluate that first because if it fails we don't want this node anyway if (attributeMatcher.operator) { if (!attributeMatch(attributeMatcher, obj)) { // if the initial operator doesn't match, we're done return false } } const attrValue = obj[qualifiedAttribute] // both valid and validRange return null for undefined, so this will skip both nodes that // do not have the attribute defined as well as those where the attribute value is invalid // and those where the value from the package.json is not a string if ((!semver.valid(attrValue) && !semver.validRange(attrValue)) || typeof attrValue !== 'string') { return false } const attrIsVersion = !!semver.valid(attrValue) let actualFunc = semverFunc // if we're asked to infer, we examine outputs to make a best guess if (actualFunc === 'infer') { if (valueIsVersion && attrIsVersion) { // two versions -> semver.eq actualFunc = 'eq' } else if (!valueIsVersion && !attrIsVersion) { // two ranges -> semver.intersects actualFunc = 'intersects' } else { // anything else -> semver.satisfies actualFunc = 'satisfies' } } if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(actualFunc)) { // both sides must be versions, but one is not if (!valueIsVersion || !attrIsVersion) { return false } return semver[actualFunc](attrValue, semverValue) } else if (['gtr', 'ltr', 'satisfies'].includes(actualFunc)) { // at least one side must be a version, but neither is if (!valueIsVersion && !attrIsVersion) { return false } return valueIsVersion ? semver[actualFunc](semverValue, attrValue) : semver[actualFunc](attrValue, semverValue) } else if (['intersects', 'subset'].includes(actualFunc)) { // these accept two ranges and since a version is also a range, anything goes return semver[actualFunc](attrValue, semverValue) } else { // user provided a function we don't know about, throw an error throw Object.assign(new Error(`\`semver.${actualFunc}\` is not a supported operator.`), { code: 'EQUERYINVALIDOPERATOR' }) } } return this.initialItems.filter((node) => { // no lookupProperties just means its a top level property, see if it matches if (!lookupProperties.length) { return nodeMatches(node, node.package) } // this code is mostly duplicated from attrPseudo to traverse into the package until we get // to our deepest requested object let objs = [node.package] for (const prop of lookupProperties) { if (prop === arrayDelimiter) { objs = objs.flat() continue } objs = objs.flatMap(obj => obj[prop] || []) const noAttr = objs.every(obj => !obj) if (noAttr) { return false } return objs.some(obj => nodeMatches(node, obj)) } }) } typePseudo () { if (!this.currentAstNode.typeValue) { return this.initialItems } return this.initialItems .flatMap(node => { const found = [] for (const edge of node.edgesIn) { if (npa(`${edge.name}@${edge.spec}`).type === this.currentAstNode.typeValue) { found.push(edge.to) } } return found }) } dedupedPseudo () { return this.initialItems.filter(node => node.target.edgesIn.size > 1) } async vulnPseudo () { if (!this.initialItems.length) { return this.initialItems } if (!this.#vulnCache) { const packages = {} // We have to map the items twice, once to get the request, and a second time to filter out the results of that request this.initialItems.map((node) => { if (node.isProjectRoot || node.package.private) { return } if (!packages[node.name]) { packages[node.name] = [] } if (!packages[node.name].includes(node.version)) { packages[node.name].push(node.version) } }) const res = await fetch('/-/npm/v1/security/advisories/bulk', { ...this.flatOptions, registry: this.flatOptions.auditRegistry || this.flatOptions.registry, method: 'POST', gzip: true, body: packages, }) this.#vulnCache = await res.json() } const advisories = this.#vulnCache const { vulns } = this.currentAstNode return this.initialItems.filter(item => { const vulnerable = advisories[item.name]?.filter(advisory => { // This could be for another version of this package elsewhere in the tree if (!semver.intersects(advisory.vulnerable_versions, item.version)) { return false } if (!vulns) { return true } // vulns are OR with each other, if any one matches we're done for (const vuln of vulns) { if (vuln.severity && !vuln.severity.includes('*')) { if (!vuln.severity.includes(advisory.severity)) { continue } } if (vuln?.cwe) { // * is special, it means "has a cwe" if (vuln.cwe.includes('*')) { if (!advisory.cwe.length) { continue } } else if (!vuln.cwe.every(cwe => advisory.cwe.includes(`CWE-${cwe}`))) { continue } } return true } }) if (vulnerable?.length) { item.queryContext = { advisories: vulnerable, } return true } return false }) } async outdatedPseudo () { const { outdatedKind = 'any' } = this.currentAstNode // filter the initialItems // NOTE: this uses a Promise.all around a map without in-line concurrency handling // since the only async action taken is retrieving the packument, which is limited // based on the max-sockets config in make-fetch-happen const initialResults = await Promise.all(this.initialItems.map(async (node) => { // the root can't be outdated, skip it if (node.isProjectRoot) { return false } // private packages can't be published, skip them if (node.package.private) { return false } // we cache the promise representing the full versions list, this helps reduce the // number of requests we send by keeping population of the cache in a single tick // making it less likely that multiple requests for the same package will be inflight if (!this.#outdatedCache.has(node.name)) { this.#outdatedCache.set(node.name, getPackageVersions(node.name, this.flatOptions)) } const availableVersions = await this.#outdatedCache.get(node.name) // we attach _all_ versions to the queryContext to allow consumers to do their own // filtering and comparisons node.queryContext.versions = availableVersions // next we further reduce the set to versions that are greater than the current one const greaterVersions = availableVersions.filter((available) => { return semver.gt(available, node.version) }) // no newer versions than the current one, drop this node from the result set if (!greaterVersions.length) { return false } // if we got here, we know that newer versions exist, if the kind is 'any' we're done if (outdatedKind === 'any') { return node } // look for newer versions that differ from current by a specific part of the semver version if (['major', 'minor', 'patch'].includes(outdatedKind)) { // filter the versions greater than our current one based on semver.diff const filteredVersions = greaterVersions.filter((version) => { return semver.diff(node.version, version) === outdatedKind }) // no available versions are of the correct diff type if (!filteredVersions.length) { return false } return node } // look for newer versions that satisfy at least one edgeIn to this node if (outdatedKind === 'in-range') { const inRangeContext = [] for (const edge of node.edgesIn) { const inRangeVersions = greaterVersions.filter((version) => { return semver.satisfies(version, edge.spec) }) // this edge has no in-range candidates, just move on if (!inRangeVersions.length) { continue } inRangeContext.push({ from: edge.from.location, versions: inRangeVersions, }) } // if we didn't find at least one match, drop this node if (!inRangeContext.length) { return false } // now add to the context each version that is in-range for each edgeIn node.queryContext.outdated = { ...node.queryContext.outdated, inRange: inRangeContext, } return node } // look for newer versions that _do not_ satisfy at least one edgeIn if (outdatedKind === 'out-of-range') { const outOfRangeContext = [] for (const edge of node.edgesIn) { const outOfRangeVersions = greaterVersions.filter((version) => { return !semver.satisfies(version, edge.spec) }) // this edge has no out-of-range candidates, skip it if (!outOfRangeVersions.length) { continue } outOfRangeContext.push({ from: edge.from.location, versions: outOfRangeVersions, }) } // if we didn't add at least one thing to the context, this node is not a match if (!outOfRangeContext.length) { return false } // attach the out-of-range context to the node node.queryContext.outdated = { ...node.queryContext.outdated, outOfRange: outOfRangeContext, } return node } // any other outdatedKind is unknown and will never match return false })) // return an array with the holes for non-matching nodes removed return initialResults.filter(Boolean) } } // operators for attribute selectors const attributeOperators = { // attribute value is equivalent '=' ({ attr, value }) { return attr === value }, // attribute value contains word '~=' ({ attr, value }) { return (attr.match(/\w+/g) || []).includes(value) }, // attribute value contains string '*=' ({ attr, value }) { return attr.includes(value) }, // attribute value is equal or starts with '|=' ({ attr, value }) { return attr.startsWith(`${value}-`) }, // attribute value starts with '^=' ({ attr, value }) { return attr.startsWith(value) }, // attribute value ends with '$=' ({ attr, value }) { return attr.endsWith(value) }, } const attributeOperator = ({ attr, value, insensitive, operator }) => { if (typeof attr === 'number') { attr = String(attr) } if (typeof attr !== 'string') { // It's an object or an array, bail return false } if (insensitive) { attr = attr.toLowerCase() } return attributeOperators[operator]({ attr, insensitive, value, }) } const attributeMatch = (matcher, obj) => { const insensitive = !!matcher.insensitive const operator = matcher.operator || '' const attribute = matcher.qualifiedAttribute let value = matcher.value || '' // return early if checking existence if (operator === '') { return Boolean(obj[attribute]) } if (insensitive) { value = value.toLowerCase() } // in case the current object is an array // then we try to match every item in the array if (Array.isArray(obj[attribute])) { return obj[attribute].find((i, index) => { const attr = obj[attribute][index] || '' return attributeOperator({ attr, value, insensitive, operator }) }) } else { const attr = obj[attribute] || '' return attributeOperator({ attr, value, insensitive, operator }) } } const edgeIsType = (node, type, seen = new Set()) => { for (const edgeIn of node.edgesIn) { // TODO Need a test with an infinite loop if (seen.has(edgeIn)) { continue } seen.add(edgeIn) if (edgeIn.type === type || edgeIn.from[type] || edgeIsType(edgeIn.from, type, seen)) { return true } } return false } const filterByType = (nodes, type) => { const found = [] for (const node of nodes) { if (node[type] || edgeIsType(node, type)) { found.push(node) } } return found } const depTypes = { // dependency '.prod' (prevResults) { const found = [] for (const node of prevResults) { if (!node.dev) { found.push(node) } } return found }, // devDependency '.dev' (prevResults) { return filterByType(prevResults, 'dev') }, // optionalDependency '.optional' (prevResults) { return filterByType(prevResults, 'optional') }, // peerDependency '.peer' (prevResults) { return filterByType(prevResults, 'peer') }, // workspace '.workspace' (prevResults) { return prevResults.filter(node => node.isWorkspace) }, // bundledDependency '.bundled' (prevResults) { return prevResults.filter(node => node.inBundle) }, } // checks if a given node has a direct parent in any of the nodes provided in // the compare nodes array const hasParent = (node, compareNodes) => { // All it takes is one so we loop and return on the first hit for (let compareNode of compareNodes) { if (compareNode.isLink) { compareNode = compareNode.target } // follows logical parent for link anscestors if (node.isTop && (node.resolveParent === compareNode)) { return true } // follows edges-in to check if they match a possible parent for (const edge of node.edgesIn) { if (edge && edge.from === compareNode) { return true } } } return false } // checks if a given node is a descendant of any of the nodes provided in the // compareNodes array const hasAscendant = (node, compareNodes, seen = new Set()) => { // TODO (future) loop over ancestry property if (hasParent(node, compareNodes)) { return true } if (node.isTop && node.resolveParent) { /* istanbul ignore if - investigate if linksIn check obviates need for this */ if (hasAscendant(node.resolveParent, compareNodes)) { return true } } for (const edge of node.edgesIn) { // TODO Need a test with an infinite loop if (seen.has(edge)) { continue } seen.add(edge) if (edge && edge.from && hasAscendant(edge.from, compareNodes, seen)) { return true } } for (const linkNode of node.linksIn) { if (hasAscendant(linkNode, compareNodes, seen)) { return true } } return false } const combinators = { // direct descendant '>' (prevResults, nextResults) { return nextResults.filter(node => hasParent(node, prevResults)) }, // any descendant ' ' (prevResults, nextResults) { return nextResults.filter(node => hasAscendant(node, prevResults)) }, // sibling '~' (prevResults, nextResults) { // Return any node in nextResults that is a sibling of (aka shares a // parent with) a node in prevResults const parentNodes = new Set() // Parents of everything in prevResults for (const node of prevResults) { for (const edge of node.edgesIn) { // edge.from always exists cause it's from another node's edgesIn parentNodes.add(edge.from) } } return nextResults.filter(node => !prevResults.includes(node) && hasParent(node, [...parentNodes]) ) }, } // get a list of available versions of a package filtered to respect --before // NOTE: this runs over each node and should not throw const getPackageVersions = async (name, opts) => { let packument try { packument = await pacote.packument(name, { ...opts, fullMetadata: false, // we only need the corgi }) } catch (err) { // if the fetch fails, log a warning and pretend there are no versions log.warn('query', `could not retrieve packument for ${name}: ${err.message}`) return [] } // start with a sorted list of all versions (lowest first) let candidates = Object.keys(packument.versions).sort(semver.compare) // if the packument has a time property, and the user passed a before flag, then // we filter this list down to only those versions that existed before the specified date if (packument.time && opts.before) { candidates = candidates.filter((version) => { // this version isn't found in the times at all, drop it if (!packument.time[version]) { return false } return Date.parse(packument.time[version]) <= opts.before }) } return candidates } const retrieveNodesFromParsedAst = async (opts) => { // when we first call this it's the parsed query. all other times it's // results.currentNode.nestedNode const rootAstNode = opts.rootAstNode if (!rootAstNode.nodes) { return new Set() } const results = new Results(opts) const astNodeQueue = new Set() // walk is sync, so we have to build up our async functions and then await them later rootAstNode.walk((nextAstNode) => { astNodeQueue.add(nextAstNode) }) for (const nextAstNode of astNodeQueue) { // This is the only place we reset currentAstNode results.currentAstNode = nextAstNode const updateFn = `${results.currentAstNode.type}Type` if (typeof results[updateFn] !== 'function') { throw Object.assign( new Error(`\`${results.currentAstNode.type}\` is not a supported selector.`), { code: 'EQUERYNOSELECTOR' } ) } await results[updateFn]() } return results.collect(rootAstNode) } const querySelectorAll = async (targetNode, query, flatOptions) => { // This never changes ever we just pass it around. But we can't scope it to // this whole file if we ever want to support concurrent calls to this // function. const inventory = [...targetNode.root.inventory.values()] // res is a Set of items returned for each parsed css ast selector const res = await retrieveNodesFromParsedAst({ initialItems: inventory, inventory, flatOptions, rootAstNode: parser(query), targetNode, }) // returns nodes ordered by realpath return [...res].sort((a, b) => localeCompare(a.location, b.location)) } module.exports = querySelectorAll