This commit is contained in:
RahulGautamSingh 2025-01-03 03:30:58 +05:30 committed by GitHub
commit e4f5fef3da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 907 additions and 32 deletions

View file

@ -706,22 +706,22 @@ You can define custom managers to handle:
- Proprietary file formats or conventions
- Popular file formats not yet supported as a manager by Renovate
Currently we only have one custom manager.
Currently we have two custom managers.
The `regex` manager which is based on using Regular Expression named capture groups.
The `jsonata` manager which is based on using JSONata queries.
You must have a named capture group matching (e.g. `(?<depName>.*)`) _or_ configure its corresponding template (e.g. `depNameTemplate`) for these fields:
You must capture/extract the following three fields _or_ configure its corresponding template (e.g. `depNameTemplate`) for these fields:
- `datasource`
- `depName` and / or `packageName`
- `currentValue`
Use named capture group matching _or_ set a corresponding template.
We recommend you use only _one_ of these methods, or you'll get confused.
We recommend that you also tell Renovate what `versioning` to use.
If the `versioning` field is missing, then Renovate defaults to using `semver` versioning.
For more details and examples about it, see our [documentation for the `regex` manager](modules/manager/regex/index.md).
For more details and examples about it, see our documentation for the [`regex` manager](modules/manager/regex/index.md) and the [`JSONata` manager](modules/manager/jsonata/index.md).
For template fields, use the triple brace `{{{ }}}` notation to avoid Handlebars escaping any special characters.
<!-- prettier-ignore -->
@ -763,6 +763,10 @@ This will lead to following update where `1.21-alpine` is the newest version of
image: my.new.registry/aRepository/andImage:1.21-alpine
```
<!-- prettier-ignore -->
!!! note
Can only be used with the custom regex maanger.
### currentValueTemplate
If the `currentValue` for a dependency is not captured with a named group then it can be defined in config using this field.
@ -786,6 +790,21 @@ Example:
}
```
```json title="Parsing a JSON file with a custom manager"
{
"customManagers": [
{
"customType": "jsonata",
"fileFormat": "json",
"fileMatch": ["file.json"],
"matchStrings": [
"packages.{ \"depName\": package, \"currentValue\": version }"
]
}
]
}
```
### datasourceTemplate
If the `datasource` for a dependency is not captured with a named group then it can be defined in config using this field.
@ -806,13 +825,40 @@ It will be compiled using Handlebars and the regex `groups` result.
If `extractVersion` cannot be captured with a named capture group in `matchString` then it can be defined manually using this field.
It will be compiled using Handlebars and the regex `groups` result.
### fileFormat
It specifies the syntax of the package file being managed by the custom JSONata manager.
This setting helps the system correctly parse and interpret the configuration file's contents.
Currently, only the `json` format is supported.
```json title="Parsing a JSON file with a custom manager"
{
"customManagers": [
{
"customType": "jsonata",
"fileFormat": "json",
"fileMatch": [".renovaterc"],
"matchStrings": [
"packages.{ \"depName\": package, \"currentValue\": version }"
]
}
]
}
```
### matchStrings
Each `matchStrings` must be a valid regular expression, optionally with named capture groups.
Each `matchStrings` must be one of the two:
1. a valid regular expression, optionally with named capture groups (if using `customType=regex`)
2. a valid, escaped [JSONata](https://docs.jsonata.org/overview.html) query (if using `customType=json`)
See [`customType`](#customtype) docs, to know more them.
Example:
```json
```json title="matchStrings with a valid regular expression"
{
"matchStrings": [
"ENV .*?_VERSION=(?<currentValue>.*) # (?<datasource>.*?)/(?<depName>.*?)\\s"
@ -820,6 +866,14 @@ Example:
}
```
```json title="matchStrings with a valid JSONata query"
{
"matchStrings": [
"packages.{ \"depName\": package, \"currentValue\": version }"
]
}
```
### matchStringsStrategy
`matchStringsStrategy` controls behavior when multiple `matchStrings` values are provided.
@ -829,6 +883,10 @@ Three options are available:
- `recursive`
- `combination`
<!--prettier-ignore-->
!!! note
Only to be used with custom regex manager.
#### any
Each provided `matchString` will be matched individually to the content of the `packageFile`.

View file

@ -2735,18 +2735,26 @@ const options: RenovateOptions[] = [
description:
'Custom manager to use. Valid only within a `customManagers` object.',
type: 'string',
allowedValues: ['regex'],
allowedValues: ['jsonata', 'regex'],
parents: ['customManagers'],
cli: false,
env: false,
},
{
name: 'fileFormat',
description:
'It specifies the syntax of the package file being managed by the custom JSONata manager.',
type: 'string',
allowedValues: ['json'],
parents: ['customManagers'],
cli: false,
env: false,
},
{
name: 'matchStrings',
description:
'Regex capture rule to use. Valid only within a `customManagers` object.',
description: 'Queries to use. Valid only within a `customManagers` object.',
type: 'array',
subType: 'string',
format: 'regex',
parents: ['customManagers'],
cli: false,
env: false,

View file

@ -1,9 +1,8 @@
import is from '@sindresorhus/is';
import jsonata from 'jsonata';
import { logger } from '../../logger';
import type {
RegexManagerConfig,
RegexManagerTemplates,
} from '../../modules/manager/custom/regex/types';
import type { RegexManagerTemplates } from '../../modules/manager/custom/regex/types';
import type { CustomManager } from '../../modules/manager/custom/types';
import { regEx } from '../../util/regex';
import type { ValidationMessage } from '../types';
@ -78,21 +77,20 @@ export function isFalseGlobal(
return false;
}
function hasField(
customManager: Partial<RegexManagerConfig>,
field: string,
): boolean {
function hasField(customManager: CustomManager, field: string): boolean {
const templateField = `${field}Template` as keyof RegexManagerTemplates;
const fieldStr =
customManager.customType === 'regex' ? `(?<${field}>` : field;
return !!(
customManager[templateField] ??
customManager.matchStrings?.some((matchString) =>
matchString.includes(`(?<${field}>`),
matchString.includes(fieldStr),
)
);
}
export function validateRegexManagerFields(
customManager: Partial<RegexManagerConfig>,
customManager: CustomManager,
currentPath: string,
errors: ValidationMessage[],
): void {
@ -136,3 +134,56 @@ export function validateRegexManagerFields(
});
}
}
export function validateJSONataManagerFields(
customManager: CustomManager,
currentPath: string,
errors: ValidationMessage[],
): void {
if (!is.nonEmptyString(customManager.fileFormat)) {
errors.push({
topic: 'Configuration Error',
message: 'Each JSONata manager must contain a fileFormat field.',
});
}
if (is.nonEmptyArray(customManager.matchStrings)) {
for (const matchString of customManager.matchStrings) {
try {
jsonata(matchString);
} catch (err) {
logger.debug(
{ err },
'customManager.matchStrings JSONata query validation error',
);
errors.push({
topic: 'Configuration Error',
message: `Invalid JSONata query for ${currentPath}: \`${matchString}\``,
});
}
}
} else {
errors.push({
topic: 'Configuration Error',
message: `Each Custom Manager must contain a non-empty matchStrings array`,
});
}
const mandatoryFields = ['currentValue', 'datasource'];
for (const field of mandatoryFields) {
if (!hasField(customManager, field)) {
errors.push({
topic: 'Configuration Error',
message: `JSONata Managers must contain ${field}Template configuration or ${field} in the query `,
});
}
}
const nameFields = ['depName', 'packageName'];
if (!nameFields.some((field) => hasField(customManager, field))) {
errors.push({
topic: 'Configuration Error',
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
});
}
}

View file

@ -706,7 +706,8 @@ describe('config/validation', () => {
currentValueTemplate: 'baz',
},
{
customType: 'regex',
customType: 'jsonata',
fileFormat: 'json',
fileMatch: ['foo'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
@ -776,6 +777,60 @@ describe('config/validation', () => {
expect(errors).toHaveLength(1);
});
it('error if no fileFormat in custom JSONata manager', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
fileMatch: ['package.json'],
matchStrings: [
'packages.{"depName": name, "currentValue": version, "datasource": "npm"}',
],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message: 'Each JSONata manager must contain a fileFormat field.',
},
]);
});
it('validates JSONata query for each matchStrings', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
fileFormat: 'json',
fileMatch: ['package.json'],
matchStrings: ['packages.{'],
depNameTemplate: 'foo',
datasourceTemplate: 'bar',
currentValueTemplate: 'baz',
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message: `Invalid JSONata query for customManagers: \`packages.{\``,
},
]);
});
// testing if we get all errors at once or not (possible), this does not include customType or fileMatch
// since they are common to all custom managers
it('validates all possible regex manager options', async () => {
@ -811,14 +866,12 @@ describe('config/validation', () => {
depTypeTemplate: 'apple',
},
{
customType: 'regex',
fileMatch: ['Dockerfile'],
matchStrings: ['ENV (?<currentValue>.*?)\\s'],
packageNameTemplate: 'foo',
datasourceTemplate: 'bar',
registryUrlTemplate: 'foobar',
extractVersionTemplate: '^(?<version>v\\d+\\.\\d+)',
depTypeTemplate: 'apple',
customType: 'jsonata',
fileFormat: 'json',
fileMatch: ['package.json'],
matchStrings: [
'packages.{"depName": depName, "currentValue": version, "datasource": "npm"}',
],
},
],
};
@ -876,6 +929,39 @@ describe('config/validation', () => {
expect(errors).toHaveLength(1);
});
it('errors if customManager fields are missing: JSONataManager', async () => {
const config: RenovateConfig = {
customManagers: [
{
customType: 'jsonata',
fileFormat: 'json',
fileMatch: ['package.json'],
matchStrings: ['packages'],
},
],
};
const { warnings, errors } = await configValidation.validateConfig(
'repo',
config,
true,
);
expect(warnings).toHaveLength(0);
expect(errors).toMatchObject([
{
topic: 'Configuration Error',
message: `JSONata Managers must contain currentValueTemplate configuration or currentValue in the query `,
},
{
topic: 'Configuration Error',
message: `JSONata Managers must contain datasourceTemplate configuration or datasource in the query `,
},
{
topic: 'Configuration Error',
message: `JSONata Managers must contain depName or packageName in the query or their templates`,
},
]);
});
it('ignore keys', async () => {
const config = {
$schema: 'renovate.json',

View file

@ -37,6 +37,7 @@ import * as regexOrGlobValidator from './validation-helpers/regex-glob-matchers'
import {
getParentName,
isFalseGlobal,
validateJSONataManagerFields,
validateNumber,
validatePlainObject,
validateRegexManagerFields,
@ -486,6 +487,7 @@ export async function validateConfig(
const allowedKeys = [
'customType',
'description',
'fileFormat',
'fileMatch',
'matchStrings',
'matchStringsStrategy',
@ -527,6 +529,13 @@ export async function validateConfig(
errors,
);
break;
case 'jsonata':
validateJSONataManagerFields(
customManager,
currentPath,
errors,
);
break;
}
} else {
errors.push({

View file

@ -1,7 +1,9 @@
import type { ManagerApi } from '../types';
import * as jsonata from './jsonata';
import * as regex from './regex';
const api = new Map<string, ManagerApi>();
export default api;
api.set('regex', regex);
api.set('jsonata', jsonata);

View file

@ -10,6 +10,8 @@ describe('modules/manager/custom/index', () => {
expect(customManager.isCustomManager('npm')).toBe(false);
expect(customManager.isCustomManager('regex')).toBe(true);
expect(customManager.isCustomManager('custom.regex')).toBe(false);
expect(customManager.isCustomManager('jsonata')).toBe(true);
expect(customManager.isCustomManager('custom.jsonata')).toBe(false);
});
});
});

View file

@ -0,0 +1,278 @@
import { codeBlock } from 'common-tags';
import { logger } from '../../../../../test/util';
import type { JsonataExtractConfig } from './types';
import { defaultConfig, extractPackageFile } from '.';
describe('modules/manager/custom/jsonata/index', () => {
it('has default config', () => {
expect(defaultConfig).toEqual({
pinDigests: false,
});
});
it('returns null when content does not match specified file format', async () => {
const res = await extractPackageFile('not-json', 'foo-file', {
fileFormat: 'json',
} as JsonataExtractConfig);
expect(res).toBeNull();
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Error while parsing file',
);
});
it('returns null when no content', async () => {
const res = await extractPackageFile('', 'foo-file', {
fileFormat: 'json',
matchStrings: [
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
],
} as JsonataExtractConfig);
expect(res).toBeNull();
});
it('extracts data when no templates are used', async () => {
const json = codeBlock`
{
"packages": [
{
"dep_name": "foo",
"package_name": "fii",
"current_value": "1.2.3",
"current_digest": "1234",
"data_source": "nuget",
"versioning": "maven",
"extract_version": "custom-extract-version",
"registry_url": "https://registry.npmjs.org",
"dep_type": "dev"
}
]
}`;
const config = {
fileFormat: 'json',
matchStrings: [
`packages.{
"depName": dep_name,
"packageName": package_name,
"currentValue": current_value,
"currentDigest": current_digest,
"datasource": data_source,
"versioning": versioning,
"extractVersion": extract_version,
"registryUrl": registry_url,
"depType": dep_type
}`,
],
};
const res = await extractPackageFile(json, 'unused', config);
expect(res).toMatchObject({
deps: [
{
depName: 'foo',
packageName: 'fii',
currentValue: '1.2.3',
currentDigest: '1234',
datasource: 'nuget',
versioning: 'maven',
extractVersion: 'custom-extract-version',
registryUrls: ['https://registry.npmjs.org/'],
depType: 'dev',
},
],
});
});
it('applies templates', async () => {
const json = codeBlock`
{
"packages": [
{
"dep_name": "foo",
"package_name": "fii",
"current_value": "1.2.3",
"current_digest": "1234",
"data_source": "nuget",
"versioning": "maven",
"extract_version": "custom-extract-version",
"registry_url": "https://registry.npmjs.org",
"dep_type": "dev"
},
{
}]
}`;
const config = {
fileFormat: 'json',
matchStrings: [
`packages.{
"depName": dep_name,
"packageName": package_name,
"currentValue": current_value,
"currentDigest": current_digest,
"datasource": data_source,
"versioning": versioning,
"extractVersion": extract_version,
"registryUrl": registry_url,
"depType": dep_type
}`,
],
depNameTemplate:
'{{#if depName}}{{depName}}{{else}}default-dep-name{{/if}}',
packageNameTemplate:
'{{#if packageName}}{{packageName}}{{else}}default-package-name{{/if}}',
currentValueTemplate:
'{{#if currentValue}}{{currentValue}}{{else}}default-current-value{{/if}}',
currentDigestTemplate:
'{{#if currentDigest}}{{currentDigest}}{{else}}default-current-digest{{/if}}',
datasourceTemplate:
'{{#if datasource}}{{datasource}}{{else}}default-datasource{{/if}}',
versioningTemplate:
'{{#if versioning}}{{versioning}}{{else}}default-versioning{{/if}}',
extractVersionTemplate:
'{{#if extractVersion}}{{extractVersion}}{{else}}default-extract-version{{/if}}',
registryUrlTemplate:
'{{#if registryUrl}}{{registryUrl}}{{else}}https://default.registry.url{{/if}}',
depTypeTemplate:
'{{#if depType}}{{depType}}{{else}}default-dep-type{{/if}}',
};
const res = await extractPackageFile(json, 'unused', config);
expect(res).toMatchObject({
deps: [
{
depName: 'foo',
packageName: 'fii',
currentValue: '1.2.3',
currentDigest: '1234',
datasource: 'nuget',
versioning: 'maven',
extractVersion: 'custom-extract-version',
registryUrls: ['https://registry.npmjs.org/'],
depType: 'dev',
},
{
depName: 'default-dep-name',
packageName: 'default-package-name',
currentValue: 'default-current-value',
currentDigest: 'default-current-digest',
datasource: 'default-datasource',
versioning: 'default-versioning',
extractVersion: 'default-extract-version',
registryUrls: ['https://default.registry.url/'],
depType: 'default-dep-type',
},
],
});
});
it('logs warning if query result does not match schema', async () => {
const json = codeBlock`
{
"packages": [
{
"dep_name": "foo",
"package_name": "fii",
"current_value": 1,
"current_digest": "1234",
"data_source": "nuget",
"versioning": "maven",
"extract_version": "custom-extract-version",
"registry_url": "https://registry.npmjs.org",
"dep_type": "dev"
}
]
}`;
const config = {
fileFormat: 'json',
matchStrings: [
`packages.{
"depName": dep_name,
"currentValue": current_value,
"datasource": data_source
}`,
],
};
const res = await extractPackageFile(json, 'unused', config);
expect(res).toBeNull();
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Query results failed schema validation',
);
});
it('returns null if no dependencies found', async () => {
const config = {
fileFormat: 'json',
matchStrings: [
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
],
};
const res = await extractPackageFile('{}', 'unused', config);
expect(logger.logger.warn).toHaveBeenCalledWith(
{
packageFile: 'unused',
jsonataQuery:
'packages.{ "depName": package, "currentValue": version, "versioning ": versioning }',
},
'The jsonata query returned no matches. Possible error, please check your query. Skipping',
);
expect(res).toBeNull();
});
it('returns null if invalid template', async () => {
const config = {
fileFormat: 'json',
matchStrings: [
`{"depName": "foo", "currentValue": "1.0.0", "datasource": "npm"}`,
],
versioningTemplate: '{{#if versioning}}{{versioning}}{{else}}semver', // invalid template
};
const res = await extractPackageFile('{}', 'unused', config);
expect(res).toBeNull();
expect(logger.logger.warn).toHaveBeenCalledWith(
expect.anything(),
'Error compiling template for JSONata manager',
);
});
it('extracts and does not apply a registryUrlTemplate if the result is an invalid url', async () => {
const config = {
fileFormat: 'json',
matchStrings: [
`{"depName": "foo", "currentValue": "1.0.0", "datasource": "npm"}`,
],
registryUrlTemplate: 'this-is-not-a-valid-url-{{depName}}',
};
const res = await extractPackageFile('{}', 'unused', config);
expect(res).not.toBeNull();
expect(logger.logger.warn).toHaveBeenCalledWith(
{ url: 'this-is-not-a-valid-url-foo' },
'Invalid JSONata manager registryUrl',
);
});
it('extracts multiple dependencies with multiple matchStrings', async () => {
const config = {
fileFormat: 'json',
matchStrings: [`{"depName": "foo"}`, `{"depName": "bar"}`],
currentValueTemplate: '1.0.0',
datasourceTemplate: 'npm',
};
const res = await extractPackageFile('{}', 'unused', config);
expect(res).toMatchObject({
deps: [
{
depName: 'foo',
currentValue: '1.0.0',
datasource: 'npm',
},
{
depName: 'bar',
currentValue: '1.0.0',
datasource: 'npm',
},
],
});
});
});

View file

@ -0,0 +1,49 @@
import is from '@sindresorhus/is';
import type { Category } from '../../../../constants';
import { logger } from '../../../../logger';
import { parseJson } from '../../../../util/common';
import type { PackageFileContent } from '../../types';
import type { JsonataExtractConfig } from './types';
import { handleMatching } from './utils';
export const categories: Category[] = ['custom'];
export const defaultConfig = {
pinDigests: false,
};
export const supportedDatasources = ['*'];
export const displayName = 'JSONata';
export async function extractPackageFile(
content: string,
packageFile: string,
config: JsonataExtractConfig,
): Promise<PackageFileContent | null> {
let json: unknown;
try {
switch (config.fileFormat) {
case 'json':
json = parseJson(content, packageFile);
break;
}
} catch (err) {
logger.warn(
{ err, fileName: packageFile, fileFormat: config.fileFormat },
'Error while parsing file',
);
return null;
}
if (is.nullOrUndefined(json)) {
return null;
}
const deps = await handleMatching(json, packageFile, config);
if (!deps.length) {
return null;
}
return {
deps,
};
}

View file

@ -0,0 +1,182 @@
With `customManagers` using `JSONata` queries you can configure Renovate so it finds dependencies in JSON files, that are not detected by its other built-in package managers.
Renovate uses the `jsonata` package to process the `json` file content. Read about the [jsonata query language](https://docs.jsonata.org/overview.html) in their readme.
The JSONata manager is unique in Renovate in because:
- It is configurable via [JSONata](https://jsonata.org/) queries
- It can extract any `datasource`
- By using the `customManagers` config, you can create multiple "JSONata managers" the same repository
### Required Fields
The first two required fields are `fileMatch` and `matchStrings`:
- `fileMatch` works the same as any manager
- `matchStrings` is a `JSONata` custom manager concept and is used for configuring a jsonata queries
#### Information that Renovate needs about the dependency
Before Renovate can look up a dependency and decide about updates, it must have this info about each dependency:
| Info type | Required | Notes | Docs |
| :--------------------------------------------------- | :------- | :-------------------------------------------------------- | :----------------------------------------------------------------------------- |
| Name of the dependency | Yes | | |
| `datasource` | Yes | Example datasources: npm, Docker, GitHub tags, and so on. | [Supported datasources](../../datasource/index.md#supported-datasources) |
| Version scheme to use. Defaults to `semver-coerced`. | Yes | You may set another version scheme, like `pep440`. | [Supported versioning schemes](../../versioning/index.md#supported-versioning) |
#### Required fields to be present in the resulting structure returned by the jsonata query
You must:
- Capture the `currentValue` of the dependency
- Capture the `depName` or `packageName`. Or use a template field: `depNameTemplate` and `packageNameTemplate`
- Capture the `datasource`, or a use `datasourceTemplate` config field
#### Optional fields you can include in the resulting structure
You may use any of these items:
- `depType`, or a use `depTypeTemplate` config field
- `versioning`, or a use `versioningTemplate` config field. If neither are present, Renovate defaults to `semver-coerced`
- `extractVersion`, or use an `extractVersionTemplate` config field
- `currentDigest`
- `registryUrl`, or a use `registryUrlTemplate` config field. If it's a valid URL, it will be converted to the `registryUrls` field as a single-length array
- `indentation`. It must be either empty, or whitespace only (otherwise `indentation` will be reset to an empty string)
### Usage
To configure it, use the following syntax:
```javascript
{
"customManagers": [
{
"customType": "jsonata",
"fileFormat": "json",
"fileMatch": ["<file match pattern>"],
"matchStrings": ['<query>'],
...
}
]
}
```
Where `<query>` is a [JSONata](https://docs.jsonata.org/overview.html) query that transform the contents into a JSON object with the following schema:
To be effective with the JSONata manager, you should understand jsonata queries. But enough examples may compensate for lack of experience.
#### Example queries
Below are some example queries for the generic JSON manager.
You can also use the [JSONata test website](https://try.jsonata.org) to experiment with queries.
```json title="Dependencies spread in different nodes, and we want to limit the extraction to a particular node"
{
"production": [
{
"version": "1.2.3",
"package": "foo"
}
],
"development": [
{
"version": "4.5.6",
"package": "bar"
}
]
}
```
Query:
```
production.{ "depName": package, "currentValue": version }
```
```json title="Dependencies spread in different nodes, and we want to extract all of them as if they were in the same node"
{
"production": [
{
"version": "1.2.3",
"package": "foo"
}
],
"development": [
{
"version": "4.5.6",
"package": "bar"
}
]
}
```
Query:
```
*.{ "depName": package, "currentValue": version }
```
```json title="The dependency name is in a JSON node name and the version is in a child leaf to that node"
{
"foo": {
"version": "1.2.3"
},
"bar": {
"version": "4.5.6"
}
}
```
Query:
```
$each(function($v, $n) { { "depName": $n, "currentValue": $v.version } })
```
```json title="The name of the dependency and the version are both value nodes of the same parent node"
{
"packages": [
{
"version": "1.2.3",
"package": "foo"
},
{
"version": "4.5.6",
"package": "bar"
}
]
}
```
Query:
```
packages.{ "depName": package, "currentValue": version }
```
```json title="The name of the dependency and the version are in the same string"
{
"packages": ["foo@1.2.3", "bar@4.5.6"]
}
```
Query:
```
$map($map(packages, function ($v) { $split($v, "@") }), function ($v) { { "depName": $v[0], "currentVersion": $v[1] } })
```
```json title="JSONata manager config to extract deps from package.json file in the renovate repository"
{
"customType": "jsonata",
"fileMatch": ["package.json"],
"matchStrings": [
"$each(dependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"dependencies\"}})",
"$each(devDependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"devDependencies\"}})",
"$each(optionalDependencies, function($v, $k) { {\"depName\":$k, \"currentValue\": $v, \"depType\": \"optionalDependencies\"}})",
"{ \"depName\": \"pnpm\", \"currentValue\": $substring(packageManager, 5), \"depType\": \"packageManager\"}"
],
"datasourceTemplate": "npm"
}
```

View file

@ -0,0 +1,16 @@
import { z } from 'zod';
const DepObjectSchema = z.object({
currentValue: z.string().optional(),
datasource: z.string().optional(),
depName: z.string().optional(),
packageName: z.string().optional(),
currentDigest: z.string().optional(),
versioning: z.string().optional(),
depType: z.string().optional(),
registryUrl: z.string().optional(),
extractVersion: z.string().optional(),
indentation: z.string().optional(),
});
export const QueryResultZodSchema = z.array(DepObjectSchema);

View file

@ -0,0 +1,25 @@
import type { ExtractConfig } from '../../types';
export interface JSONataManagerTemplates {
depNameTemplate?: string;
packageNameTemplate?: string;
datasourceTemplate?: string;
versioningTemplate?: string;
depTypeTemplate?: string;
currentValueTemplate?: string;
currentDigestTemplate?: string;
extractVersionTemplate?: string;
registryUrlTemplate?: string;
}
export interface JSONataManagerConfig extends JSONataManagerTemplates {
fileFormat: string;
matchStrings: string[];
}
export interface JsonataExtractConfig
extends ExtractConfig,
JSONataManagerTemplates {
fileFormat: string;
matchStrings: string[];
}

View file

@ -0,0 +1,104 @@
import is from '@sindresorhus/is';
import jsonata from 'jsonata';
import { logger } from '../../../../logger';
import * as template from '../../../../util/template';
import { parseUrl } from '../../../../util/url';
import type { PackageDependency } from '../../types';
import type { ValidMatchFields } from '../utils';
import { checkIsValidDependency, validMatchFields } from '../utils';
import { QueryResultZodSchema } from './schema';
import type { JSONataManagerTemplates, JsonataExtractConfig } from './types';
export async function handleMatching(
json: unknown,
packageFile: string,
config: JsonataExtractConfig,
): Promise<PackageDependency[]> {
let results: Record<string, string>[] = [];
const { matchStrings: jsonataQueries } = config;
for (const query of jsonataQueries) {
// won't fail as this is verified during config validation
const jsonataExpression = jsonata(query);
// this does not throw error, just returns undefined if no matches
let queryResult = (await jsonataExpression.evaluate(json)) ?? [];
if (is.emptyObject(queryResult) || is.emptyArray(queryResult)) {
logger.warn(
{
jsonataQuery: query,
packageFile,
},
'The jsonata query returned no matches. Possible error, please check your query. Skipping',
);
return [];
}
queryResult = is.array(queryResult) ? queryResult : [queryResult];
const parsed = QueryResultZodSchema.safeParse(queryResult);
if (parsed.success) {
results = results.concat(parsed.data);
} else {
logger.warn(
{ err: parsed.error, jsonataQuery: query, packageFile, queryResult },
'Query results failed schema validation',
);
}
}
return results
.map((dep) => createDependency(dep, config))
.filter(is.truthy)
.filter((dep) =>
checkIsValidDependency(dep, packageFile, 'custom.jsonata'),
);
}
export function createDependency(
queryResult: Record<string, string>,
config: JsonataExtractConfig,
): PackageDependency | null {
const dependency: PackageDependency = {};
for (const field of validMatchFields) {
const fieldTemplate = `${field}Template` as keyof JSONataManagerTemplates;
const tmpl = config[fieldTemplate];
if (tmpl) {
try {
const compiled = template.compile(tmpl, queryResult, false);
updateDependency(field, compiled, dependency);
} catch {
logger.warn(
{ template: tmpl },
'Error compiling template for JSONata manager',
);
return null;
}
} else if (queryResult[field]) {
updateDependency(field, queryResult[field], dependency);
}
}
return dependency;
}
function updateDependency(
field: ValidMatchFields,
value: string,
dependency: PackageDependency,
): PackageDependency {
switch (field) {
case 'registryUrl': {
const url = parseUrl(value)?.toString();
if (!url) {
logger.warn({ url: value }, 'Invalid JSONata manager registryUrl');
break;
}
dependency.registryUrls = [url];
break;
}
default:
dependency[field] = value;
break;
}
return dependency;
}

View file

@ -1,10 +1,15 @@
import type { JSONataManagerConfig } from './jsonata/types';
import type { RegexManagerConfig } from './regex/types';
export interface CustomExtractConfig extends Partial<RegexManagerConfig> {}
export interface CustomExtractConfig
extends Partial<RegexManagerConfig>,
Partial<JSONataManagerConfig> {}
export type CustomManagerName = 'regex';
export type CustomManagerName = 'jsonata' | 'regex';
export interface CustomManager extends Partial<RegexManagerConfig> {
export interface CustomManager
extends Partial<RegexManagerConfig>,
Partial<JSONataManagerConfig> {
customType: CustomManagerName;
fileMatch: string[];
}