diff options
author | Rhys Arkins <[email protected]> | 2024-10-08 13:32:30 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2024-10-08 11:32:30 +0000 |
commit | 32ecb4ccc83fb380f84e20c3f1cad93aa797b773 (patch) | |
tree | 190b96d0435079fcf1b81aad4537485fa51ea407 | |
parent | da4ee8b8741491ba85981f55708e89ac812f0fb4 (diff) | |
download | renovate-32ecb4ccc83fb380f84e20c3f1cad93aa797b773.tar.gz renovate-32ecb4ccc83fb380f84e20c3f1cad93aa797b773.zip |
feat(packageRules): matchJsonata (#31826)38.114.0
Co-authored-by: Sebastian Poxhofer <[email protected]>
-rw-r--r-- | docs/usage/configuration-options.md | 17 | ||||
-rw-r--r-- | lib/config/options/index.ts | 12 | ||||
-rw-r--r-- | lib/config/types.ts | 1 | ||||
-rw-r--r-- | lib/config/validation.spec.ts | 14 | ||||
-rw-r--r-- | lib/config/validation.ts | 13 | ||||
-rw-r--r-- | lib/util/package-rules/jsonata.spec.ts | 82 | ||||
-rw-r--r-- | lib/util/package-rules/jsonata.ts | 37 | ||||
-rw-r--r-- | lib/util/package-rules/matchers.ts | 2 |
8 files changed, 178 insertions, 0 deletions
diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 76f23dc8c7f..0c5fe8e0b20 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2856,6 +2856,23 @@ The following example matches any file in directories starting with `app/`: It is recommended that you avoid using "negative" globs, like `**/!(package.json)`, because such patterns might still return true if they match against the lock file name (e.g. `package-lock.json`). +### matchJsonata + +Use the `matchJsonata` field to define custom matching logic using [JSONata](https://jsonata.org/) query logic. +Renovate will evaluate the provided JSONata expressions against the passed values (`manager`, `packageName`, etc.). + +See [the JSONata docs](https://docs.jsonata.org/) for more details on JSONata syntax. + +Here are some example `matchJsonata` strings for inspiration: + +``` +$exists(deprecationMessage) +$exists(vulnerabilityFixVersion) +manager = 'dockerfile' and depType = 'final' +``` + +`matchJsonata` accepts an array of strings, and will return `true` if any of those JSONata expressions evaluate to `true`. + ### matchManagers Use this field to restrict rules to a particular package manager. e.g. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index f7e49f535aa..21900eb2e3a 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1513,6 +1513,18 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'matchJsonata', + description: + 'A JSONata expression to match against the full config object. Valid only within a `packageRules` object.', + type: 'array', + subType: 'string', + stage: 'package', + parents: ['packageRules'], + mergeable: true, + cli: false, + env: false, + }, // Version behavior { name: 'allowedVersions', diff --git a/lib/config/types.ts b/lib/config/types.ts index d92dc7886c5..8bc41bb87e1 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -381,6 +381,7 @@ export interface PackageRule matchRepositories?: string[]; matchSourceUrls?: string[]; matchUpdateTypes?: UpdateType[]; + matchJsonata?: string[]; registryUrls?: string[] | null; vulnerabilitySeverity?: string; vulnerabilityFixVersion?: string; diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index e7dc924ca57..914d761b825 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -129,6 +129,20 @@ describe('config/validation', () => { expect(errors).toMatchSnapshot(); }); + it('catches invalid jsonata expressions', async () => { + const config = { + packageRules: [ + { + matchJsonata: ['packageName = "foo"', '{{{something wrong}'], + enabled: true, + }, + ], + }; + const { errors } = await configValidation.validateConfig('repo', config); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('Invalid JSONata expression'); + }); + it('catches invalid allowedVersions regex', async () => { const config = { packageRules: [ diff --git a/lib/config/validation.ts b/lib/config/validation.ts index b6218b84be9..18ce6a31ca4 100644 --- a/lib/config/validation.ts +++ b/lib/config/validation.ts @@ -443,6 +443,7 @@ export async function validateConfig( 'matchCurrentAge', 'matchRepositories', 'matchNewValue', + 'matchJsonata', ]; if (key === 'packageRules') { for (const [subIndex, packageRule] of val.entries()) { @@ -846,6 +847,18 @@ export async function validateConfig( } } } + + if (key === 'matchJsonata' && is.array(val, is.string)) { + for (const expression of val) { + const res = getExpression(expression); + if (res instanceof Error) { + errors.push({ + topic: 'Configuration Error', + message: `Invalid JSONata expression for ${currentPath}: ${res.message}`, + }); + } + } + } } function sortAll(a: ValidationMessage, b: ValidationMessage): number { diff --git a/lib/util/package-rules/jsonata.spec.ts b/lib/util/package-rules/jsonata.spec.ts new file mode 100644 index 00000000000..c81315672ae --- /dev/null +++ b/lib/util/package-rules/jsonata.spec.ts @@ -0,0 +1,82 @@ +import { JsonataMatcher } from './jsonata'; + +describe('util/package-rules/jsonata', () => { + const matcher = new JsonataMatcher(); + + it('should return true for a matching JSONata expression', async () => { + const result = await matcher.matches( + { depName: 'lodash' }, + { matchJsonata: ['depName = "lodash"'] }, + ); + expect(result).toBeTrue(); + }); + + it('should return false for a non-matching JSONata expression', async () => { + const result = await matcher.matches( + { depName: 'lodash' }, + { matchJsonata: ['depName = "react"'] }, + ); + expect(result).toBeFalse(); + }); + + it('should return false for an invalid JSONata expression', async () => { + const result = await matcher.matches( + { depName: 'lodash' }, + { matchJsonata: ['depName = '] }, + ); + expect(result).toBeFalse(); + }); + + it('should return null if matchJsonata is not defined', async () => { + const result = await matcher.matches({ depName: 'lodash' }, {}); + expect(result).toBeNull(); + }); + + it('should return true for a complex JSONata expression', async () => { + const result = await matcher.matches( + { depName: 'lodash', version: '4.17.21' }, + { matchJsonata: ['depName = "lodash" and version = "4.17.21"'] }, + ); + expect(result).toBeTrue(); + }); + + it('should return false for a complex JSONata expression with non-matching version', async () => { + const result = await matcher.matches( + { depName: 'lodash', version: '4.17.20' }, + { matchJsonata: ['depName = "lodash" and version = "4.17.21"'] }, + ); + expect(result).toBeFalse(); + }); + + it('should return true for a JSONata expression with nested properties', async () => { + const result = await matcher.matches( + { dep: { name: 'lodash', version: '4.17.21' } }, + { matchJsonata: ['dep.name = "lodash" and dep.version = "4.17.21"'] }, + ); + expect(result).toBeTrue(); + }); + + it('should return false for a JSONata expression with nested properties and non-matching version', async () => { + const result = await matcher.matches( + { dep: { name: 'lodash', version: '4.17.20' } }, + { matchJsonata: ['dep.name = "lodash" and dep.version = "4.17.21"'] }, + ); + expect(result).toBeFalse(); + }); + + it('should return true if any JSONata expression matches', async () => { + const result = await matcher.matches( + { depName: 'lodash' }, + { matchJsonata: ['depName = "react"', 'depName = "lodash"'] }, + ); + expect(result).toBeTrue(); + }); + + it('should catch evaluate errors', async () => { + const result = await matcher.matches( + { depName: 'lodash' }, + { matchJsonata: ['$notafunction()'] }, + ); + expect(result).toBeFalse(); + }); +}); diff --git a/lib/util/package-rules/jsonata.ts b/lib/util/package-rules/jsonata.ts new file mode 100644 index 00000000000..1fa0ff834fd --- /dev/null +++ b/lib/util/package-rules/jsonata.ts @@ -0,0 +1,37 @@ +import type { PackageRule, PackageRuleInputConfig } from '../../config/types'; +import { logger } from '../../logger'; +import { getExpression } from '../jsonata'; +import { Matcher } from './base'; + +export class JsonataMatcher extends Matcher { + override async matches( + inputConfig: PackageRuleInputConfig, + { matchJsonata }: PackageRule, + ): Promise<boolean | null> { + if (!matchJsonata) { + return null; + } + + for (const expressionStr of matchJsonata) { + const expression = getExpression(expressionStr); + if (expression instanceof Error) { + logger.warn( + { errorMessage: expression.message }, + 'Invalid JSONata expression', + ); + } else { + try { + const result = await expression.evaluate(inputConfig); + if (result) { + // Only one needs to match, so return early + return true; + } + } catch (err) { + logger.warn({ err }, 'Error evaluating JSONata expression'); + } + } + } + // None matched, so return false + return false; + } +} diff --git a/lib/util/package-rules/matchers.ts b/lib/util/package-rules/matchers.ts index 9ad89afe0a9..f47d18a47fa 100644 --- a/lib/util/package-rules/matchers.ts +++ b/lib/util/package-rules/matchers.ts @@ -7,6 +7,7 @@ import { DatasourcesMatcher } from './datasources'; import { DepNameMatcher } from './dep-names'; import { DepTypesMatcher } from './dep-types'; import { FileNamesMatcher } from './files'; +import { JsonataMatcher } from './jsonata'; import { ManagersMatcher } from './managers'; import { MergeConfidenceMatcher } from './merge-confidence'; import { NewValueMatcher } from './new-value'; @@ -40,3 +41,4 @@ matchers.push(new UpdateTypesMatcher()); matchers.push(new SourceUrlsMatcher()); matchers.push(new NewValueMatcher()); matchers.push(new CurrentAgeMatcher()); +matchers.push(new JsonataMatcher()); |