diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 76f23dc8c7fc0e..0c5fe8e0b20c3e 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 f7e49f535aa030..21900eb2e3adf1 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 d92dc7886c5bfd..8bc41bb87e197d 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 e7dc924ca5711f..914d761b825772 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 b6218b84be9697..18ce6a31ca4a49 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 00000000000000..c81315672ae2f3 --- /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 00000000000000..1fa0ff834fd94c --- /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 { + 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 9ad89afe0a92aa..f47d18a47fa963 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());