Skip to content

Commit

Permalink
feat(packageRules): matchJsonata (#31826)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian Poxhofer <[email protected]>
  • Loading branch information
rarkins and secustor authored Oct 8, 2024
1 parent da4ee8b commit 32ecb4c
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 0 deletions.
17 changes: 17 additions & 0 deletions docs/usage/configuration-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions lib/config/options/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export interface PackageRule
matchRepositories?: string[];
matchSourceUrls?: string[];
matchUpdateTypes?: UpdateType[];
matchJsonata?: string[];
registryUrls?: string[] | null;
vulnerabilitySeverity?: string;
vulnerabilityFixVersion?: string;
Expand Down
14 changes: 14 additions & 0 deletions lib/config/validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
13 changes: 13 additions & 0 deletions lib/config/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ export async function validateConfig(
'matchCurrentAge',
'matchRepositories',
'matchNewValue',
'matchJsonata',
];
if (key === 'packageRules') {
for (const [subIndex, packageRule] of val.entries()) {
Expand Down Expand Up @@ -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 {
Expand Down
82 changes: 82 additions & 0 deletions lib/util/package-rules/jsonata.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 37 additions & 0 deletions lib/util/package-rules/jsonata.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions lib/util/package-rules/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,3 +41,4 @@ matchers.push(new UpdateTypesMatcher());
matchers.push(new SourceUrlsMatcher());
matchers.push(new NewValueMatcher());
matchers.push(new CurrentAgeMatcher());
matchers.push(new JsonataMatcher());

0 comments on commit 32ecb4c

Please sign in to comment.