From 1d5fc812be8168dd56b71c3761cf99968fa4a2ad Mon Sep 17 00:00:00 2001 From: nlf Date: Thu, 18 Aug 2022 11:59:06 -0700 Subject: [PATCH 1/3] deps: @npmcli/query@1.2.0 --- node_modules/@npmcli/query/lib/index.js | 32 ++++++++++++++++++++++++- node_modules/@npmcli/query/package.json | 2 +- package-lock.json | 8 +++---- workspaces/arborist/package.json | 2 +- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/node_modules/@npmcli/query/lib/index.js b/node_modules/@npmcli/query/lib/index.js index 36a8c700caae1..44f539ee0a125 100644 --- a/node_modules/@npmcli/query/lib/index.js +++ b/node_modules/@npmcli/query/lib/index.js @@ -118,10 +118,40 @@ const fixupNestedPseudo = astNode => { transformAst(newRootNode) } +// :semver(, [selector], [function]) const fixupSemverSpecs = astNode => { - const children = astNode.nodes[0].nodes + // the first child node contains the version or range, most likely as a tag and a series of + // classes. we combine them into a single string here. this is the only required input. + const children = astNode.nodes.shift().nodes const value = children.reduce((res, i) => `${res}${String(i)}`, '') + // next, if we have 2 nodes left then the user called us with a total of 3. that means the + // last one tells us what specific semver function the user is requesting, so we pull that out + let semverFunc + if (astNode.nodes.length === 2) { + const funcNode = astNode.nodes.pop().nodes[0] + if (funcNode.type === 'tag') { + semverFunc = funcNode.value + } + } + + // now if there's a node left, that node is our selector. since that is the last remaining + // child node, we call fixupAttr on ourselves so that the attribute selectors get parsed + if (astNode.nodes.length === 1) { + fixupAttr(astNode) + } else { + // we weren't provided a selector, so we default to `[version]`. note, there's no default + // operator here. that's because we don't know yet if the user has provided us a version + // or range to assert against + astNode.attributeMatcher = { + insensitive: false, + attribute: 'version', + qualifiedAttribute: 'version', + } + astNode.lookupProperties = [] + } + + astNode.semverFunc = semverFunc astNode.semverValue = value astNode.nodes.length = 0 } diff --git a/node_modules/@npmcli/query/package.json b/node_modules/@npmcli/query/package.json index 0c9247e0bb23b..610d0b7189182 100644 --- a/node_modules/@npmcli/query/package.json +++ b/node_modules/@npmcli/query/package.json @@ -1,6 +1,6 @@ { "name": "@npmcli/query", - "version": "1.1.1", + "version": "1.2.0", "description": "npm query parser and tools", "main": "lib/index.js", "scripts": { diff --git a/package-lock.json b/package-lock.json index ce443bce578ac..19fa9c0fdb848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1057,9 +1057,9 @@ } }, "node_modules/@npmcli/query": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-1.1.1.tgz", - "integrity": "sha512-UF3I0fD94wzQ84vojMO2jDB8ibjRSTqhi8oz2mzVKiJ9gZHbeGlu9kzPvgHuGDK0Hf2cARhWtTfCDHNEwlL9hg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-1.2.0.tgz", + "integrity": "sha512-uWglsUM3PjBLgTSmZ3/vygeGdvWEIZ3wTUnzGFbprC/RtvQSaT+GAXu1DXmSFj2bD3oOZdcRm1xdzsV2z1YWdw==", "dependencies": { "npm-package-arg": "^9.1.0", "postcss-selector-parser": "^6.0.10", @@ -10494,7 +10494,7 @@ "@npmcli/name-from-folder": "^1.0.1", "@npmcli/node-gyp": "^2.0.0", "@npmcli/package-json": "^2.0.0", - "@npmcli/query": "^1.1.1", + "@npmcli/query": "^1.2.0", "@npmcli/run-script": "^4.1.3", "bin-links": "^3.0.0", "cacache": "^16.0.6", diff --git a/workspaces/arborist/package.json b/workspaces/arborist/package.json index 124cb6650ce00..88c36b984fc3c 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -11,7 +11,7 @@ "@npmcli/name-from-folder": "^1.0.1", "@npmcli/node-gyp": "^2.0.0", "@npmcli/package-json": "^2.0.0", - "@npmcli/query": "^1.1.1", + "@npmcli/query": "^1.2.0", "@npmcli/run-script": "^4.1.3", "bin-links": "^3.0.0", "cacache": "^16.0.6", From 5e15f064b3f713d4eb47859ae7b732dbfd7910a4 Mon Sep 17 00:00:00 2001 From: nlf Date: Thu, 18 Aug 2022 12:00:04 -0700 Subject: [PATCH 2/3] 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']], From 7d6e8859d3252e8e707a483eeafb93bc06ea4e7b Mon Sep 17 00:00:00 2001 From: nlf Date: Thu, 18 Aug 2022 12:27:18 -0700 Subject: [PATCH 3/3] docs: add documentation for expanded :semver selector --- docs/content/using-npm/dependency-selectors.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/content/using-npm/dependency-selectors.md b/docs/content/using-npm/dependency-selectors.md index c96057c798ef5..c8fde495f2ba6 100644 --- a/docs/content/using-npm/dependency-selectors.md +++ b/docs/content/using-npm/dependency-selectors.md @@ -58,10 +58,26 @@ The [`npm query`](/commands/npm-query) commmand exposes a new dependency selecto - `:extraneous` when a dependency exists but is not defined as a dependency of any node - `:invalid` when a dependency version is out of its ancestors specified range - `:missing` when a dependency is not found on disk -- `:semver()` matching a valid [`node-semver`](https://github.com/npm/node-semver) spec +- `:semver(, [selector], [function])` match a valid [`node-semver`](https://github.com/npm/node-semver) version or range to a selector - `:path()` [glob](https://www.npmjs.com/package/glob) matching based on dependencies path relative to the project - `:type()` [based on currently recognized types](https://github.com/npm/npm-package-arg#result-object) +##### `:semver(, [selector], [function])` + +The `:semver()` pseudo selector allows comparing fields from each node's `package.json` using [semver](https://github.com/npm/node-semver#readme) methods. It accepts up to 3 parameters, all but the first of which are optional. + +- `spec` a semver version or range +- `selector` an attribute selector for each node (default `[version]`) +- `function` a semver method to apply, one of: `satisfies`, `intersects`, `subset`, `gt`, `gte`, `gtr`, `lt`, `lte`, `ltr`, `eq`, `neq` or the special function `infer` (default `infer`) + +When the special `infer` function is used the `spec` and the actual value from the node are compared. If both are versions, according to `semver.valid()`, `eq` is used. If both values are ranges, according to `!semver.valid()`, `intersects` is used. If the values are mixed types `satisfies` is used. + +Some examples: + +- `:semver(^1.0.0)` returns every node that has a `version` satisfied by the provided range `^1.0.0` +- `:semver(16.0.0, :attr(engines, [node]))` returns every node which has an `engines.node` property satisfying the version `16.0.0` +- `:semver(1.0.0, [version], lt)` every node with a `version` less than `1.0.0` + #### [Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) The attribute selector evaluates the key/value pairs in `package.json` if they are `String`s.