From 09c46e815fe1b44658d32dd760a689e8b2c8d92f Mon Sep 17 00:00:00 2001 From: nlf Date: Thu, 18 Aug 2022 12:00:04 -0700 Subject: [PATCH] feat(arborist): allow for selectors and function names with :semver pseudo selector --- workspaces/arborist/lib/query-selector-all.js | 114 +++++++++++++++++- .../arborist/test/query-selector-all.js | 108 +++++++++++++++++ 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index a3eac5ddc1238..64b280d58a23a 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -3,8 +3,9 @@ const { resolve } = require('path') const { parser, arrayDelimiter } = require('@npmcli/query') const localeCompare = require('@isaacs/string-locale-compare')('en') -const npa = require('npm-package-arg') +const log = require('proc-log') const minimatch = require('minimatch') +const npa = require('npm-package-arg') const semver = require('semver') // handle results for parsed query asts, results are stored in a map that has a @@ -291,11 +292,115 @@ class Results { } semverPseudo () { - if (!this.currentAstNode.semverValue) { + 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 } - return this.initialItems.filter(node => - semver.satisfies(node.version, this.currentAstNode.semverValue)) + + 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 () { @@ -358,6 +463,7 @@ const attributeOperator = ({ attr, value, insensitive, operator }) => { if (insensitive) { attr = attr.toLowerCase() } + return attributeOperators[operator]({ attr, insensitive, diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 3bfe34bd8ef2c..6ad5a69565e67 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -52,6 +52,9 @@ t.test('query-selector-all', async t => { name: 'abbrev', version: '1.1.1', license: 'ISC', + engines: { + node: '^16.0.0', + }, }), }, b: t.fixture('symlink', '../b'), @@ -62,6 +65,9 @@ t.test('query-selector-all', async t => { dependencies: { moo: '3.0.0', }, + engines: { + node: '>= 14.0.0', + }, arbitrary: { foo: [ false, @@ -89,6 +95,10 @@ t.test('query-selector-all', async t => { scripts: { test: 'tap', }, + engines: { + // intentionally invalid range + node: 'nope', + }, }), }, foo: { @@ -254,6 +264,18 @@ t.test('query-selector-all', async t => { 'should throw in invalid selector' ) + t.rejects( + q(tree, ':semver(1.0.0, [version], eqqq)'), + { code: 'EQUERYINVALIDOPERATOR' }, + 'should throw on invalid semver operator' + ) + + t.rejects( + q(tree, ':semver(nope)'), + { code: 'EQUERYINVALIDSEMVER' }, + 'should throw on invalid semver value' + ) + // :scope pseudo const [nodeFoo] = await q(tree, '#foo') const scopeRes = await querySelectorAll(nodeFoo, ':scope') @@ -559,6 +581,92 @@ t.test('query-selector-all', async t => { ]], [':semver(=1.4.0)', ['bar@1.4.0']], [':semver(1.4.0 || 2.2.2)', ['foo@2.2.2', 'bar@1.4.0']], + [':semver(^16.0.0, :attr(engines, [node]))', ['abbrev@1.1.1', 'bar@2.0.0']], + [':semver(18.0.0, :attr(engines, [node]))', ['bar@2.0.0']], + [':semver(^16.0.0, :attr(engines, [node^=">="]))', ['bar@2.0.0']], + [':semver(3.0.0, [version], eq)', ['moo@3.0.0']], + [':semver(^3.0.0, [version], eq)', []], + [':semver(1.0.0, [version], neq)', [ + '@npmcli/abbrev@2.0.0-beta.45', + 'abbrev@1.1.1', + 'bar@2.0.0', + 'dasher@2.0.0', + 'foo@2.2.2', + 'bar@1.4.0', + 'moo@3.0.0', + ]], + [':semver(^1.0.0, [version], neq)', []], + [':semver(2.0.0, [version], gt)', ['foo@2.2.2', 'moo@3.0.0']], + [':semver(^2.0.0, [version], gt)', []], + [':semver(2.0.0, [version], gte)', [ + 'bar@2.0.0', + 'dasher@2.0.0', + 'foo@2.2.2', + 'moo@3.0.0', + ]], + [':semver(^2.0.0, [version], gte)', []], + [':semver(1.1.1, [version], lt)', [ + 'query-selector-all-tests@1.0.0', + 'a@1.0.0', + 'b@1.0.0', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'ipsum@npm:sit@1.0.0', + 'lorem@1.0.0', + 'recur@1.0.0', + 'sive@1.0.0', + ]], + [':semver(^1.1.1, [version], lt)', []], + [':semver(1.1.1, [version], lte)', [ + 'query-selector-all-tests@1.0.0', + 'a@1.0.0', + 'b@1.0.0', + 'abbrev@1.1.1', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'ipsum@npm:sit@1.0.0', + 'lorem@1.0.0', + 'recur@1.0.0', + 'sive@1.0.0', + ]], + [':semver(^1.1.1, [version], lte)', []], + [':semver(^14.0.0, :attr(engines, [node]), intersects)', ['bar@2.0.0']], + [':semver(>=14, :attr(engines, [node]), subset)', ['abbrev@1.1.1', 'bar@2.0.0']], + [':semver(^2.0.0, [version], gtr)', ['moo@3.0.0']], + [':semver(^2.0.0, :attr(engines, [node]), gtr)', []], + [':semver(20.0.0, :attr(engines, [node]), gtr)', ['abbrev@1.1.1']], + [':semver(1.0.1, [version], gtr)', [ + 'query-selector-all-tests@1.0.0', + 'a@1.0.0', + 'b@1.0.0', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'ipsum@npm:sit@1.0.0', + 'lorem@1.0.0', + 'recur@1.0.0', + 'sive@1.0.0', + ]], + [':semver(^1.1.1, [version], ltr)', [ + 'query-selector-all-tests@1.0.0', + 'a@1.0.0', + 'b@1.0.0', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'ipsum@npm:sit@1.0.0', + 'lorem@1.0.0', + 'recur@1.0.0', + 'sive@1.0.0', + ]], + [':semver(^1.1.1, :attr(engines, [node]), ltr)', []], + [':semver(0.0.1, :attr(engines, [node]), ltr)', ['abbrev@1.1.1', 'bar@2.0.0']], + [':semver(1.1.1, [version], ltr)', [ + '@npmcli/abbrev@2.0.0-beta.45', + 'bar@2.0.0', + 'dasher@2.0.0', + 'foo@2.2.2', + 'bar@1.4.0', + 'moo@3.0.0', + ]], // attr pseudo [':attr([name=dasher])', ['dasher@2.0.0']],