aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRhys Arkins <[email protected]>2024-10-08 13:32:30 +0200
committerGitHub <[email protected]>2024-10-08 11:32:30 +0000
commit32ecb4ccc83fb380f84e20c3f1cad93aa797b773 (patch)
tree190b96d0435079fcf1b81aad4537485fa51ea407
parentda4ee8b8741491ba85981f55708e89ac812f0fb4 (diff)
downloadrenovate-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.md17
-rw-r--r--lib/config/options/index.ts12
-rw-r--r--lib/config/types.ts1
-rw-r--r--lib/config/validation.spec.ts14
-rw-r--r--lib/config/validation.ts13
-rw-r--r--lib/util/package-rules/jsonata.spec.ts82
-rw-r--r--lib/util/package-rules/jsonata.ts37
-rw-r--r--lib/util/package-rules/matchers.ts2
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());