From 664dc808e7a95747134d9793a4df36deec7b2a88 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 May 2023 23:51:20 +0300 Subject: [PATCH] refactor(composer): Use schema for parsing (#21520) --- lib/modules/manager/composer/artifacts.ts | 28 +- lib/modules/manager/composer/extract.spec.ts | 2 +- lib/modules/manager/composer/extract.ts | 212 +----------- lib/modules/manager/composer/schema.spec.ts | 115 +++++++ lib/modules/manager/composer/schema.ts | 318 ++++++++++++++++++ lib/modules/manager/composer/types.ts | 46 --- lib/modules/manager/composer/update-locked.ts | 12 +- lib/modules/manager/composer/utils.spec.ts | 185 +++++----- lib/modules/manager/composer/utils.ts | 58 ++-- 9 files changed, 589 insertions(+), 387 deletions(-) create mode 100644 lib/modules/manager/composer/schema.spec.ts create mode 100644 lib/modules/manager/composer/schema.ts diff --git a/lib/modules/manager/composer/artifacts.ts b/lib/modules/manager/composer/artifacts.ts index cc6acbbf266ff0..a902bf827db023 100644 --- a/lib/modules/manager/composer/artifacts.ts +++ b/lib/modules/manager/composer/artifacts.ts @@ -1,5 +1,6 @@ import is from '@sindresorhus/is'; import { quote } from 'shlex'; +import { z } from 'zod'; import { SYSTEM_INSUFFICIENT_DISK_SPACE, TEMPORARY_ERROR, @@ -18,10 +19,12 @@ import { import { getRepoStatus } from '../../../util/git'; import * as hostRules from '../../../util/host-rules'; import { regEx } from '../../../util/regex'; +import { Json } from '../../../util/schema-utils'; import { GitTagsDatasource } from '../../datasource/git-tags'; import { PackagistDatasource } from '../../datasource/packagist'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types'; -import type { AuthJson, ComposerLock } from './types'; +import { Lockfile, PackageFile } from './schema'; +import type { AuthJson } from './types'; import { extractConstraints, findGithubToken, @@ -105,10 +108,19 @@ export async function updateArtifacts({ }: UpdateArtifact): Promise { logger.debug(`composer.updateArtifacts(${packageFileName})`); + const file = Json.pipe(PackageFile).parse(newPackageFileContent); + const lockFileName = packageFileName.replace(regEx(/\.json$/), '.lock'); - const existingLockFileContent = await readLocalFile(lockFileName, 'utf8'); - if (!existingLockFileContent) { - logger.debug('No composer.lock found'); + const lockfile = await z + .string() + .transform((f) => readLocalFile(f, 'utf8')) + .pipe(Json) + .pipe(Lockfile) + .nullable() + .catch(null) + .parseAsync(lockFileName); + if (!lockfile) { + logger.debug('Composer: unable to read lockfile'); return null; } @@ -118,12 +130,8 @@ export async function updateArtifacts({ try { await writeLocalFile(packageFileName, newPackageFileContent); - const existingLockFile: ComposerLock = JSON.parse(existingLockFileContent); const constraints = { - ...extractConstraints( - JSON.parse(newPackageFileContent), - existingLockFile - ), + ...extractConstraints(file, lockfile), ...config.constraints, }; @@ -150,7 +158,7 @@ export async function updateArtifacts({ const commands: string[] = []; // Determine whether install is required before update - if (requireComposerDependencyInstallation(existingLockFile)) { + if (requireComposerDependencyInstallation(lockfile)) { const preCmd = 'composer'; const preArgs = 'install' + getComposerArguments(config, composerToolConstraint); diff --git a/lib/modules/manager/composer/extract.spec.ts b/lib/modules/manager/composer/extract.spec.ts index 617db0e7245fe2..9c7d71a4cfd14b 100644 --- a/lib/modules/manager/composer/extract.spec.ts +++ b/lib/modules/manager/composer/extract.spec.ts @@ -279,7 +279,7 @@ describe('modules/manager/composer/extract', () => { }); it('extracts dependencies with lock file', async () => { - fs.readLocalFile.mockResolvedValue('some content'); + fs.readLocalFile.mockResolvedValue('{}'); const res = await extractPackageFile(requirements1, packageFile); expect(res).toMatchSnapshot(); expect(res?.deps).toHaveLength(33); diff --git a/lib/modules/manager/composer/extract.ts b/lib/modules/manager/composer/extract.ts index ae40abec469249..22eb4bdae316d4 100644 --- a/lib/modules/manager/composer/extract.ts +++ b/lib/modules/manager/composer/extract.ts @@ -1,215 +1,15 @@ -import is from '@sindresorhus/is'; import { logger } from '../../../logger'; -import { readLocalFile } from '../../../util/fs'; -import { regEx } from '../../../util/regex'; -import { GitTagsDatasource } from '../../datasource/git-tags'; -import { GithubTagsDatasource } from '../../datasource/github-tags'; -import { PackagistDatasource } from '../../datasource/packagist'; -import { api as semverComposer } from '../../versioning/composer'; -import type { PackageDependency, PackageFileContent } from '../types'; -import type { - ComposerConfig, - ComposerLock, - ComposerManagerData, - ComposerRepositories, - Repo, -} from './types'; - -/** - * The regUrl is expected to be a base URL. GitLab composer repository installation guide specifies - * to use a base URL containing packages.json. Composer still works in this scenario by determining - * whether to add / remove packages.json from the URL. - * - * See https://github.com/composer/composer/blob/750a92b4b7aecda0e5b2f9b963f1cb1421900675/src/Composer/Repository/ComposerRepository.php#L815 - */ -function transformRegUrl(url: string): string { - return url.replace(regEx(/(\/packages\.json)$/), ''); -} - -/** - * Parse the repositories field from a composer.json - * - * Entries with type vcs or git will be added to repositories, - * other entries will be added to registryUrls - */ -function parseRepositories( - repoJson: ComposerRepositories, - repositories: Record, - registryUrls: string[] -): void { - try { - let packagist = true; - Object.entries(repoJson).forEach(([key, repo]) => { - if (is.object(repo)) { - const name = is.array(repoJson) ? repo.name : key; - - switch (repo.type) { - case 'vcs': - case 'git': - case 'path': - repositories[name!] = repo; - break; - case 'composer': - registryUrls.push(transformRegUrl(repo.url)); - break; - case 'package': - logger.debug( - { url: repo.url }, - 'type package is not supported yet' - ); - } - if (repo.packagist === false || repo['packagist.org'] === false) { - packagist = false; - } - } // istanbul ignore else: invalid repo - else if (['packagist', 'packagist.org'].includes(key) && repo === false) { - packagist = false; - } - }); - if (packagist) { - registryUrls.push('https://packagist.org'); - } else { - logger.debug('Disabling packagist.org'); - } - } catch (e) /* istanbul ignore next */ { - logger.debug( - { repositories: repoJson }, - 'Error parsing composer.json repositories config' - ); - } -} +import type { PackageFileContent } from '../types'; +import { ComposerExtract } from './schema'; export async function extractPackageFile( content: string, fileName: string ): Promise { - logger.trace(`composer.extractPackageFile(${fileName})`); - let composerJson: ComposerConfig; - try { - composerJson = JSON.parse(content); - } catch (err) { - logger.debug(`Invalid JSON in ${fileName}`); + const res = await ComposerExtract.safeParseAsync({ content, fileName }); + if (!res.success) { + logger.debug({ fileName, err: res.error }, 'Composer: extract failed'); return null; } - const repositories: Record = {}; - const registryUrls: string[] = []; - const res: PackageFileContent = { deps: [] }; - - // handle lockfile - const lockfilePath = fileName.replace(regEx(/\.json$/), '.lock'); - const lockContents = await readLocalFile(lockfilePath, 'utf8'); - let lockParsed: ComposerLock | undefined; - if (lockContents) { - logger.debug(`Found composer lock file ${fileName}`); - res.lockFiles = [lockfilePath]; - try { - lockParsed = JSON.parse(lockContents) as ComposerLock; - } catch (err) /* istanbul ignore next */ { - logger.warn({ err }, 'Error processing composer.lock'); - } - } - - // handle composer.json repositories - if (composerJson.repositories) { - parseRepositories(composerJson.repositories, repositories, registryUrls); - } - - const deps: PackageDependency[] = []; - const depTypes: ('require' | 'require-dev')[] = ['require', 'require-dev']; - for (const depType of depTypes) { - if (composerJson[depType]) { - try { - for (const [depName, version] of Object.entries( - composerJson[depType]! - )) { - const currentValue = version.trim(); - if (depName === 'php') { - deps.push({ - depType, - depName, - currentValue, - datasource: GithubTagsDatasource.id, - packageName: 'php/php-src', - extractVersion: '^php-(?.*)$', - }); - } else { - // Default datasource and packageName - let datasource = PackagistDatasource.id; - let packageName = depName; - - // Check custom repositories by type - if (repositories[depName]) { - switch (repositories[depName].type) { - case 'vcs': - case 'git': - datasource = GitTagsDatasource.id; - packageName = repositories[depName].url; - break; - case 'path': - deps.push({ - depType, - depName, - currentValue, - skipReason: 'path-dependency', - }); - continue; - } - } - const dep: PackageDependency = { - depType, - depName, - currentValue, - datasource, - }; - if (depName !== packageName) { - dep.packageName = packageName; - } - if (!depName.includes('/')) { - dep.skipReason = 'unsupported'; - } - if (lockParsed) { - const lockField = - depType === 'require' - ? 'packages' - : /* istanbul ignore next */ 'packages-dev'; - const lockedDep = lockParsed[lockField]?.find( - (item) => item.name === dep.depName - ); - if (lockedDep && semverComposer.isVersion(lockedDep.version)) { - dep.lockedVersion = lockedDep.version.replace(regEx(/^v/i), ''); - } - } - if ( - !dep.skipReason && - (!repositories[depName] || - repositories[depName].type === 'composer') && - registryUrls.length !== 0 - ) { - dep.registryUrls = registryUrls; - } - deps.push(dep); - } - } - } catch (err) /* istanbul ignore next */ { - logger.debug({ fileName, depType, err }, 'Error parsing composer.json'); - return null; - } - } - } - if (!deps.length) { - return null; - } - res.deps = deps; - if (is.string(composerJson.type)) { - const managerData: ComposerManagerData = { - composerJsonType: composerJson.type, - }; - res.managerData = managerData; - } - - if (composerJson.require?.php) { - res.extractedConstraints = { php: composerJson.require.php }; - } - - return res; + return res.data; } diff --git a/lib/modules/manager/composer/schema.spec.ts b/lib/modules/manager/composer/schema.spec.ts new file mode 100644 index 00000000000000..d105b70690e926 --- /dev/null +++ b/lib/modules/manager/composer/schema.spec.ts @@ -0,0 +1,115 @@ +import { Repos, ReposArray, ReposRecord } from './schema'; + +describe('modules/manager/composer/schema', () => { + describe('ReposRecord', () => { + it('parses default values', () => { + expect(ReposRecord.parse({})).toEqual([]); + }); + + it('parses repositories', () => { + expect( + ReposRecord.parse({ + wpackagist: { type: 'composer', url: 'https://wpackagist.org' }, + someGit: { type: 'vcs', url: 'https://some-vcs.com' }, + somePath: { type: 'path', url: '/some/path' }, + packagist: false, + 'packagist.org': false, + foo: 'bar', + }) + ).toEqual([ + { type: 'composer', url: 'https://wpackagist.org' }, + { name: 'someGit', type: 'git', url: 'https://some-vcs.com' }, + { name: 'somePath', type: 'path', url: '/some/path' }, + { type: 'disable-packagist' }, + { type: 'disable-packagist' }, + ]); + }); + }); + + describe('ReposArray', () => { + it('parses default values', () => { + expect(ReposArray.parse([])).toEqual([]); + }); + + it('parses repositories', () => { + expect( + ReposArray.parse([ + { + type: 'composer', + url: 'https://wpackagist.org', + }, + { name: 'someGit', type: 'vcs', url: 'https://some-vcs.com' }, + { name: 'somePath', type: 'path', url: '/some/path' }, + { packagist: false }, + { 'packagist.org': false }, + { foo: 'bar' }, + ]) + ).toEqual([ + { type: 'composer', url: 'https://wpackagist.org' }, + { name: 'someGit', type: 'git', url: 'https://some-vcs.com' }, + { name: 'somePath', type: 'path', url: '/some/path' }, + { type: 'disable-packagist' }, + { type: 'disable-packagist' }, + ]); + }); + }); + + describe('Repos', () => { + it('parses default values', () => { + expect(Repos.parse(null)).toEqual({ + pathRepos: {}, + gitRepos: {}, + registryUrls: null, + }); + }); + + it('parses repositories', () => { + expect( + Repos.parse([ + { + name: 'wpackagist', + type: 'composer', + url: 'https://wpackagist.org', + }, + { name: 'someGit', type: 'vcs', url: 'https://some-vcs.com' }, + { name: 'somePath', type: 'path', url: '/some/path' }, + ]) + ).toEqual({ + pathRepos: { + somePath: { name: 'somePath', type: 'path', url: '/some/path' }, + }, + registryUrls: ['https://wpackagist.org', 'https://packagist.org'], + gitRepos: { + someGit: { + name: 'someGit', + type: 'git', + url: 'https://some-vcs.com', + }, + }, + }); + }); + + it(`parses repositories with packagist disabled`, () => { + expect( + Repos.parse({ + wpackagist: { type: 'composer', url: 'https://wpackagist.org' }, + someGit: { type: 'vcs', url: 'https://some-vcs.com' }, + somePath: { type: 'path', url: '/some/path' }, + packagist: false, + }) + ).toEqual({ + pathRepos: { + somePath: { name: 'somePath', type: 'path', url: '/some/path' }, + }, + registryUrls: ['https://wpackagist.org'], + gitRepos: { + someGit: { + name: 'someGit', + type: 'git', + url: 'https://some-vcs.com', + }, + }, + }); + }); + }); +}); diff --git a/lib/modules/manager/composer/schema.ts b/lib/modules/manager/composer/schema.ts new file mode 100644 index 00000000000000..fb8c2a8af2c9d7 --- /dev/null +++ b/lib/modules/manager/composer/schema.ts @@ -0,0 +1,318 @@ +import { z } from 'zod'; +import { logger } from '../../../logger'; +import { readLocalFile } from '../../../util/fs'; +import { regEx } from '../../../util/regex'; +import { Json, LooseArray, LooseRecord } from '../../../util/schema-utils'; +import { GitTagsDatasource } from '../../datasource/git-tags'; +import { GithubTagsDatasource } from '../../datasource/github-tags'; +import { PackagistDatasource } from '../../datasource/packagist'; +import { api as semverComposer } from '../../versioning/composer'; +import type { PackageDependency, PackageFileContent } from '../types'; +import type { ComposerManagerData } from './types'; + +export const ComposerRepo = z.object({ + type: z.literal('composer'), + /** + * The regUrl is expected to be a base URL. GitLab composer repository installation guide specifies + * to use a base URL containing packages.json. Composer still works in this scenario by determining + * whether to add / remove packages.json from the URL. + * + * See https://github.com/composer/composer/blob/750a92b4b7aecda0e5b2f9b963f1cb1421900675/src/Composer/Repository/ComposerRepository.php#L815 + */ + url: z.string().transform((url) => url.replace(/\/packages\.json$/, '')), +}); +export type ComposerRepo = z.infer; + +export const GitRepo = z.object({ + type: z.enum(['vcs', 'git']).transform(() => 'git' as const), + url: z.string(), +}); +export type GitRepo = z.infer; + +export const PathRepo = z.object({ + type: z.literal('path'), + url: z.string(), +}); +export type PathRepo = z.infer; + +export const Repo = z.discriminatedUnion('type', [ + ComposerRepo, + GitRepo, + PathRepo, +]); +export type Repo = z.infer; + +export const NamedRepo = z.discriminatedUnion('type', [ + ComposerRepo, + GitRepo.extend({ name: z.string() }), + PathRepo.extend({ name: z.string() }), +]); +export type NamedRepo = z.infer; + +const DisablePackagist = z.object({ type: z.literal('disable-packagist') }); +export type DisablePackagist = z.infer; + +export const ReposRecord = LooseRecord(z.union([Repo, z.literal(false)]), { + onError: ({ error: err }) => { + logger.warn({ err }, 'Composer: error parsing repositories object'); + }, +}).transform((repos) => { + const result: (NamedRepo | DisablePackagist)[] = []; + for (const [name, repo] of Object.entries(repos)) { + if (repo === false) { + if (name === 'packagist' || name === 'packagist.org') { + result.push({ type: 'disable-packagist' }); + } + continue; + } + + if (repo.type === 'path' || repo.type === 'git') { + result.push({ name, ...repo }); + continue; + } + + if (repo.type === 'composer') { + result.push(repo); + continue; + } + } + + return result; +}); +export type ReposRecord = z.infer; + +export const ReposArray = LooseArray( + z.union([ + NamedRepo, + z + .union([ + z.object({ packagist: z.literal(false) }), + z.object({ 'packagist.org': z.literal(false) }), + ]) + .transform((): DisablePackagist => ({ type: 'disable-packagist' })), + ]), + { + onError: ({ error: err }) => { + logger.warn({ err }, 'Composer: error parsing repositories array'); + }, + } +).transform((repos) => repos.filter((x): x is NamedRepo => x !== null)); +export type ReposArray = z.infer; + +export const Repos = z + .union([ReposRecord, ReposArray]) + .default([]) // Prevents warnings for packages without repositories field + .catch(({ error: err }) => { + logger.warn({ err }, 'Composer: repositories parsing error'); + return []; + }) + .transform((repos) => { + let packagist = true; + const repoUrls: string[] = []; + const gitRepos: Record = {}; + const pathRepos: Record = {}; + + for (const repo of repos) { + if (repo.type === 'composer') { + repoUrls.push(repo.url); + } else if (repo.type === 'git') { + gitRepos[repo.name] = repo; + } else if (repo.type === 'path') { + pathRepos[repo.name] = repo; + } else if (repo.type === 'disable-packagist') { + packagist = false; + } + } + + if (packagist && repoUrls.length) { + repoUrls.push('https://packagist.org'); + } + const registryUrls = repoUrls.length ? repoUrls : null; + + return { registryUrls, gitRepos, pathRepos }; + }); +export type Repos = z.infer; + +const RequireDefs = LooseRecord(z.string().transform((x) => x.trim())).catch( + {} +); + +export const PackageFile = z + .object({ + type: z.string().optional(), + config: z + .object({ + platform: z.object({ + php: z.string(), + }), + }) + .nullable() + .catch(null), + repositories: Repos, + require: RequireDefs, + 'require-dev': RequireDefs, + }) + .transform( + ({ + type: composerJsonType, + config, + repositories, + require, + 'require-dev': requireDev, + }) => ({ + composerJsonType, + config, + repositories, + require, + requireDev, + }) + ); +export type PackageFile = z.infer; + +const LockedPackage = z.object({ + name: z.string(), + version: z.string(), +}); +type LockedPackage = z.infer; + +export const Lockfile = z + .object({ + 'plugin-api-version': z.string().optional(), + packages: LooseArray(LockedPackage).catch([]), + 'packages-dev': LooseArray(LockedPackage).catch([]), + }) + .transform( + ({ + 'plugin-api-version': pluginApiVersion, + packages, + 'packages-dev': packagesDev, + }) => ({ pluginApiVersion, packages, packagesDev }) + ); +export type Lockfile = z.infer; + +export const ComposerExtract = z + .object({ + content: z.string(), + fileName: z.string(), + }) + .transform(({ content, fileName }) => { + const lockfileName = fileName.replace(/\.json$/, '.lock'); + return { + file: content, + lockfileName, + lockfile: lockfileName, + }; + }) + .pipe( + z.object({ + file: Json.pipe(PackageFile), + lockfileName: z.string(), + lockfile: z + .string() + .transform((lockfileName) => readLocalFile(lockfileName, 'utf8')) + .pipe(Json) + .pipe(Lockfile) + .nullable() + .catch(({ error: err }) => { + logger.warn({ err }, 'Composer: lockfile parsing error'); + return null; + }), + }) + ) + .transform(({ file, lockfile, lockfileName }) => { + const { composerJsonType, require, requireDev } = file; + const { registryUrls, gitRepos, pathRepos } = file.repositories; + + const deps: PackageDependency[] = []; + + const profiles = [ + { + depType: 'require', + req: require, + locked: lockfile?.packages ?? [], + }, + { + depType: 'require-dev', + req: requireDev, + locked: lockfile?.packagesDev ?? [], + }, + ]; + + for (const { depType, req, locked } of profiles) { + for (const [depName, currentValue] of Object.entries(req)) { + if (depName === 'php') { + deps.push({ + depType, + depName, + currentValue, + datasource: GithubTagsDatasource.id, + packageName: 'php/php-src', + extractVersion: '^php-(?.*)$', + }); + continue; + } + + if (pathRepos[depName]) { + deps.push({ + depType, + depName, + currentValue, + skipReason: 'path-dependency', + }); + continue; + } + + const dep: PackageDependency = { + depType, + depName, + currentValue, + }; + + if (!depName.includes('/')) { + dep.skipReason = 'unsupported'; + } + + const lockedDep = locked.find((item) => item.name === depName); + if (lockedDep && semverComposer.isVersion(lockedDep.version)) { + dep.lockedVersion = lockedDep.version.replace(regEx(/^v/i), ''); + } + + const gitRepo = gitRepos[depName]; + if (gitRepo) { + dep.datasource = GitTagsDatasource.id; + dep.packageName = gitRepo.url; + deps.push(dep); + continue; + } + + dep.datasource = PackagistDatasource.id; + + if (registryUrls) { + dep.registryUrls = registryUrls; + } + + deps.push(dep); + } + } + + if (!deps.length) { + return null; + } + + const res: PackageFileContent = { deps }; + + if (composerJsonType) { + res.managerData = { composerJsonType }; + } + + if (require.php) { + res.extractedConstraints = { php: require.php }; + } + + if (lockfile) { + res.lockFiles = [lockfileName]; + } + + return res; + }); +export type ComposerExtract = z.infer; diff --git a/lib/modules/manager/composer/types.ts b/lib/modules/manager/composer/types.ts index c46f294d0cf861..491dc7eac3f78c 100644 --- a/lib/modules/manager/composer/types.ts +++ b/lib/modules/manager/composer/types.ts @@ -1,49 +1,3 @@ -// istanbul ignore file: types only -export interface Repo { - name?: string; - type: 'composer' | 'git' | 'package' | 'path' | 'vcs'; - packagist?: boolean; - 'packagist.org'?: boolean; - url: string; -} -export type ComposerRepositories = Record | Repo[]; - -export interface ComposerConfig { - type?: string; - /** - * Setting a fixed PHP version (e.g. {"php": "7.0.3"}) will let you fake the - * platform version so that you can emulate a production env or define your - * target platform in the config. - * See https://getcomposer.org/doc/06-config.md#platform - */ - config?: { - platform?: { - php?: string; - }; - }; - /** - * A repositories field can be an array of Repo objects or an object of repoName: Repo - * Also it can be a boolean (usually false) to disable packagist. - * (Yes this can be confusing, as it is also not properly documented in the composer docs) - * See https://getcomposer.org/doc/05-repositories.md#disabling-packagist-org - */ - repositories?: ComposerRepositories; - - require?: Record; - 'require-dev'?: Record; -} - -export interface ComposerLockPackage { - name: string; - version: string; -} - -export interface ComposerLock { - 'plugin-api-version'?: string; - packages?: ComposerLockPackage[]; - 'packages-dev'?: ComposerLockPackage[]; -} - export interface ComposerManagerData { composerJsonType?: string; } diff --git a/lib/modules/manager/composer/update-locked.ts b/lib/modules/manager/composer/update-locked.ts index fcfaa89d2f11c9..469ac1a81df64b 100644 --- a/lib/modules/manager/composer/update-locked.ts +++ b/lib/modules/manager/composer/update-locked.ts @@ -1,7 +1,8 @@ import { logger } from '../../../logger'; +import { Json } from '../../../util/schema-utils'; import { api as composer } from '../../versioning/composer'; import type { UpdateLockedConfig, UpdateLockedResult } from '../types'; -import type { ComposerLock } from './types'; +import { Lockfile } from './schema'; export function updateLockedDependency( config: UpdateLockedConfig @@ -12,12 +13,11 @@ export function updateLockedDependency( `composer.updateLockedDependency: ${depName}@${currentVersion} -> ${newVersion} [${lockFile}]` ); try { - const locked = JSON.parse(lockFileContent!) as ComposerLock; + const lockfile = Json.pipe(Lockfile).parse(lockFileContent); if ( - locked.packages?.find( - (entry) => - entry.name === depName && - composer.equals(entry.version || '', newVersion) + lockfile?.packages.find( + ({ name, version }) => + name === depName && composer.equals(version, newVersion) ) ) { return { status: 'already-updated' }; diff --git a/lib/modules/manager/composer/utils.spec.ts b/lib/modules/manager/composer/utils.spec.ts index 481df517e6f260..c9e6117fccfd5b 100644 --- a/lib/modules/manager/composer/utils.spec.ts +++ b/lib/modules/manager/composer/utils.spec.ts @@ -1,6 +1,7 @@ import { GlobalConfig } from '../../../config/global'; import * as hostRules from '../../../util/host-rules'; import { GitTagsDatasource } from '../../datasource/git-tags'; +import { Lockfile, PackageFile } from './schema'; import { extractConstraints, findGithubToken, @@ -21,114 +22,121 @@ describe('modules/manager/composer/utils', () => { describe('extractConstraints', () => { it('returns from require', () => { - expect( - extractConstraints( - { require: { php: '>=5.3.2', 'composer/composer': '1.1.0' } }, - {} - ) - ).toEqual({ php: '>=5.3.2', composer: '1.1.0' }); + const file = PackageFile.parse({ + require: { php: '>=5.3.2', 'composer/composer': '1.1.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + php: '>=5.3.2', + composer: '1.1.0', + }); }); it('returns platform php version', () => { - expect( - extractConstraints( - { - config: { platform: { php: '7.4.27' } }, - require: { php: '~7.4 || ~8.0' }, - }, - {} - ) - ).toEqual({ composer: '1.*', php: '<=7.4.27' }); + const file = PackageFile.parse({ + config: { platform: { php: '7.4.27' } }, + require: { php: '~7.4 || ~8.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '1.*', + php: '<=7.4.27', + }); }); it('returns platform 0 minor php version', () => { - expect( - extractConstraints( - { - config: { platform: { php: '7.0.5' } }, - require: { php: '^7.0 || ~8.0' }, - }, - {} - ) - ).toEqual({ composer: '1.*', php: '<=7.0.5' }); + const file = PackageFile.parse({ + config: { platform: { php: '7.0.5' } }, + require: { php: '^7.0 || ~8.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '1.*', + php: '<=7.0.5', + }); }); it('returns platform 0 patch php version', () => { - expect( - extractConstraints( - { - config: { platform: { php: '7.4.0' } }, - require: { php: '^7.0 || ~8.0' }, - }, - {} - ) - ).toEqual({ composer: '1.*', php: '<=7.4.0' }); + const file = PackageFile.parse({ + config: { platform: { php: '7.4.0' } }, + require: { php: '^7.0 || ~8.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '1.*', + php: '<=7.4.0', + }); }); it('returns platform lowest minor php version', () => { - expect( - extractConstraints( - { - config: { platform: { php: '7' } }, - require: { php: '^7.0 || ~8.0' }, - }, - {} - ) - ).toEqual({ composer: '1.*', php: '<=7.0.0' }); + const file = PackageFile.parse({ + config: { platform: { php: '7' } }, + require: { php: '^7.0 || ~8.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '1.*', + php: '<=7.0.0', + }); }); it('returns platform lowest patch php version', () => { - expect( - extractConstraints( - { - config: { platform: { php: '7.4' } }, - require: { php: '~7.4 || ~8.0' }, - }, - {} - ) - ).toEqual({ composer: '1.*', php: '<=7.4.0' }); + const file = PackageFile.parse({ + config: { platform: { php: '7.4' } }, + require: { php: '~7.4 || ~8.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '1.*', + php: '<=7.4.0', + }); }); it('returns from require-dev', () => { - expect( - extractConstraints( - { 'require-dev': { 'composer/composer': '1.1.0' } }, - {} - ) - ).toEqual({ composer: '1.1.0' }); + const file = PackageFile.parse({ + 'require-dev': { 'composer/composer': '1.1.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ composer: '1.1.0' }); }); it('returns from composer platform require', () => { - expect( - extractConstraints({ require: { php: '^8.1', composer: '2.2.0' } }, {}) - ).toEqual({ php: '^8.1', composer: '2.2.0' }); + const file = PackageFile.parse({ + require: { php: '^8.1', composer: '2.2.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ + php: '^8.1', + composer: '2.2.0', + }); }); it('returns from composer platform require-dev', () => { - expect( - extractConstraints({ 'require-dev': { composer: '^2.2' } }, {}) - ).toEqual({ composer: '^2.2' }); + const file = PackageFile.parse({ 'require-dev': { composer: '^2.2' } }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ composer: '^2.2' }); }); it('returns from composer-runtime-api', () => { - expect( - extractConstraints( - { require: { 'composer-runtime-api': '^1.1.0' } }, - {} - ) - ).toEqual({ composer: '^1.1' }); + const file = PackageFile.parse({ + require: { 'composer-runtime-api': '^1.1.0' }, + }); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ composer: '^1.1' }); }); it('returns from plugin-api-version', () => { - expect(extractConstraints({}, { 'plugin-api-version': '1.1.0' })).toEqual( - { - composer: '^1.1', - } - ); + const file = PackageFile.parse({}); + const lockfile = Lockfile.parse({ 'plugin-api-version': '1.1.0' }); + expect(extractConstraints(file, lockfile)).toEqual({ + composer: '^1.1', + }); }); it('fallback to 1.*', () => { - expect(extractConstraints({}, {})).toEqual({ composer: '1.*' }); + const file = PackageFile.parse({}); + const lockfile = Lockfile.parse({}); + expect(extractConstraints(file, lockfile)).toEqual({ composer: '1.*' }); }); }); @@ -276,27 +284,24 @@ describe('modules/manager/composer/utils', () => { describe('requireComposerDependencyInstallation', () => { it('returns true when symfony/flex has been installed', () => { - expect( - requireComposerDependencyInstallation({ - packages: [{ name: 'symfony/flex', version: '1.17.1' }], - }) - ).toBeTrue(); + const lockfile = Lockfile.parse({ + packages: [{ name: 'symfony/flex', version: '1.17.1' }], + }); + expect(requireComposerDependencyInstallation(lockfile)).toBeTrue(); }); it('returns true when symfony/flex has been installed as dev dependency', () => { - expect( - requireComposerDependencyInstallation({ - 'packages-dev': [{ name: 'symfony/flex', version: '1.17.1' }], - }) - ).toBeTrue(); + const lockfile = Lockfile.parse({ + 'packages-dev': [{ name: 'symfony/flex', version: '1.17.1' }], + }); + expect(requireComposerDependencyInstallation(lockfile)).toBeTrue(); }); it('returns false when symfony/flex has not been installed', () => { - expect( - requireComposerDependencyInstallation({ - packages: [{ name: 'symfony/console', version: '5.4.0' }], - }) - ).toBeFalse(); + const lockfile = Lockfile.parse({ + packages: [{ name: 'symfony/console', version: '5.4.0' }], + }); + expect(requireComposerDependencyInstallation(lockfile)).toBeFalse(); }); }); diff --git a/lib/modules/manager/composer/utils.ts b/lib/modules/manager/composer/utils.ts index 8342798971adb7..5a73be6c9120ac 100644 --- a/lib/modules/manager/composer/utils.ts +++ b/lib/modules/manager/composer/utils.ts @@ -7,7 +7,7 @@ import type { HostRuleSearchResult } from '../../../types'; import type { ToolConstraint } from '../../../util/exec/types'; import { api, id as composerVersioningId } from '../../versioning/composer'; import type { UpdateArtifactsConfig } from '../types'; -import type { ComposerConfig, ComposerLock } from './types'; +import type { Lockfile, PackageFile } from './schema'; export { composerVersioningId }; @@ -59,53 +59,55 @@ export function getPhpConstraint( return null; } -export function requireComposerDependencyInstallation( - lock: ComposerLock -): boolean { +export function requireComposerDependencyInstallation({ + packages, + packagesDev, +}: Lockfile): boolean { return ( - lock.packages?.some((p) => depRequireInstall.has(p.name)) === true || - lock['packages-dev']?.some((p) => depRequireInstall.has(p.name)) === true + packages.some((p) => depRequireInstall.has(p.name)) === true || + packagesDev.some((p) => depRequireInstall.has(p.name)) === true ); } export function extractConstraints( - composerJson: ComposerConfig, - lockParsed: ComposerLock + { config, require, requireDev }: PackageFile, + { pluginApiVersion }: Lockfile ): Record { const res: Record = { composer: '1.*' }; // extract php - if (composerJson.config?.platform?.php) { - const major = api.getMajor(composerJson.config.platform.php); - const minor = api.getMinor(composerJson.config.platform.php) ?? 0; - const patch = api.getPatch(composerJson.config.platform.php) ?? 0; + const phpVersion = config?.platform.php; + if (phpVersion) { + const major = api.getMajor(phpVersion); + const minor = api.getMinor(phpVersion) ?? 0; + const patch = api.getPatch(phpVersion) ?? 0; res.php = `<=${major}.${minor}.${patch}`; - } else if (composerJson.require?.php) { - res.php = composerJson.require.php; + } else if (require.php) { + res.php = require.php; } // extract direct composer dependency - if (composerJson.require?.['composer/composer']) { - res.composer = composerJson.require?.['composer/composer']; - } else if (composerJson['require-dev']?.['composer/composer']) { - res.composer = composerJson['require-dev']?.['composer/composer']; + if (require['composer/composer']) { + res.composer = require['composer/composer']; + } else if (requireDev['composer/composer']) { + res.composer = requireDev['composer/composer']; } // composer platform package - else if (composerJson.require?.['composer']) { - res.composer = composerJson.require?.['composer']; - } else if (composerJson['require-dev']?.['composer']) { - res.composer = composerJson['require-dev']?.['composer']; + else if (require['composer']) { + res.composer = require['composer']; + } else if (requireDev['composer']) { + res.composer = requireDev['composer']; } // check last used composer version - else if (lockParsed?.['plugin-api-version']) { - const major = api.getMajor(lockParsed?.['plugin-api-version']); - const minor = api.getMinor(lockParsed?.['plugin-api-version']); + else if (pluginApiVersion) { + const major = api.getMajor(pluginApiVersion); + const minor = api.getMinor(pluginApiVersion); res.composer = `^${major}.${minor}`; } // check composer api dependency - else if (composerJson.require?.['composer-runtime-api']) { - const major = api.getMajor(composerJson.require?.['composer-runtime-api']); - const minor = api.getMinor(composerJson.require?.['composer-runtime-api']); + else if (require['composer-runtime-api']) { + const major = api.getMajor(require['composer-runtime-api']); + const minor = api.getMinor(require['composer-runtime-api']); res.composer = `^${major}.${minor}`; } return res;