Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: :semver query expansion #5324

Merged
merged 3 commits into from
Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/content/using-npm/dependency-selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<spec>)` matching a valid [`node-semver`](https://github.com/npm/node-semver) spec
- `:semver(<spec>, [selector], [function])` match a valid [`node-semver`](https://github.com/npm/node-semver) version or range to a selector
- `:path(<path>)` [glob](https://www.npmjs.com/package/glob) matching based on dependencies path relative to the project
- `:type(<type>)` [based on currently recognized types](https://github.com/npm/npm-package-arg#result-object)

##### `:semver(<spec>, [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.
Expand Down
32 changes: 31 additions & 1 deletion node_modules/@npmcli/query/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,40 @@ const fixupNestedPseudo = astNode => {
transformAst(newRootNode)
}

// :semver(<version|range>, [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
}
Expand Down
2 changes: 1 addition & 1 deletion node_modules/@npmcli/query/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
114 changes: 110 additions & 4 deletions workspaces/arborist/lib/query-selector-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 () {
Expand Down Expand Up @@ -358,6 +463,7 @@ const attributeOperator = ({ attr, value, insensitive, operator }) => {
if (insensitive) {
attr = attr.toLowerCase()
}

return attributeOperators[operator]({
attr,
insensitive,
Expand Down
2 changes: 1 addition & 1 deletion workspaces/arborist/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions workspaces/arborist/test/query-selector-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -62,6 +65,9 @@ t.test('query-selector-all', async t => {
dependencies: {
moo: '3.0.0',
},
engines: {
node: '>= 14.0.0',
},
arbitrary: {
foo: [
false,
Expand Down Expand Up @@ -89,6 +95,10 @@ t.test('query-selector-all', async t => {
scripts: {
test: 'tap',
},
engines: {
// intentionally invalid range
node: 'nope',
},
}),
},
foo: {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -559,6 +581,92 @@ t.test('query-selector-all', async t => {
]],
[':semver(=1.4.0)', ['[email protected]']],
[':semver(1.4.0 || 2.2.2)', ['[email protected]', '[email protected]']],
[':semver(^16.0.0, :attr(engines, [node]))', ['[email protected]', '[email protected]']],
[':semver(18.0.0, :attr(engines, [node]))', ['[email protected]']],
[':semver(^16.0.0, :attr(engines, [node^=">="]))', ['[email protected]']],
[':semver(3.0.0, [version], eq)', ['[email protected]']],
[':semver(^3.0.0, [version], eq)', []],
[':semver(1.0.0, [version], neq)', [
'@npmcli/[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^1.0.0, [version], neq)', []],
[':semver(2.0.0, [version], gt)', ['[email protected]', '[email protected]']],
[':semver(^2.0.0, [version], gt)', []],
[':semver(2.0.0, [version], gte)', [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^2.0.0, [version], gte)', []],
[':semver(1.1.1, [version], lt)', [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'ipsum@npm:[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^1.1.1, [version], lt)', []],
[':semver(1.1.1, [version], lte)', [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'ipsum@npm:[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^1.1.1, [version], lte)', []],
[':semver(^14.0.0, :attr(engines, [node]), intersects)', ['[email protected]']],
[':semver(>=14, :attr(engines, [node]), subset)', ['[email protected]', '[email protected]']],
[':semver(^2.0.0, [version], gtr)', ['[email protected]']],
[':semver(^2.0.0, :attr(engines, [node]), gtr)', []],
[':semver(20.0.0, :attr(engines, [node]), gtr)', ['[email protected]']],
[':semver(1.0.1, [version], gtr)', [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'ipsum@npm:[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^1.1.1, [version], ltr)', [
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'ipsum@npm:[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],
[':semver(^1.1.1, :attr(engines, [node]), ltr)', []],
[':semver(0.0.1, :attr(engines, [node]), ltr)', ['[email protected]', '[email protected]']],
[':semver(1.1.1, [version], ltr)', [
'@npmcli/[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
'[email protected]',
]],

// attr pseudo
[':attr([name=dasher])', ['[email protected]']],
Expand Down