Consolidate Lockfile classes: move toString() and versioning utils to cli-node

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-25 11:59:30 +01:00
parent b42fcdca2e
commit 61cb976207
21 changed files with 184 additions and 265 deletions
+1
View File
@@ -24,3 +24,4 @@ export * from './git';
export * from './monorepo';
export * from './concurrency';
export * from './roles';
export * from './versioning';
@@ -15,6 +15,7 @@
*/
import { Lockfile } from './Lockfile';
import { createMockDirectory } from '@backstage/backend-test-utils';
const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -29,7 +30,74 @@ __metadata:
cacheKey: 8
`;
describe('New Lockfile', () => {
const mockLegacy = `${LEGACY_HEADER}
a@^1:
version "1.0.1"
resolved "https://my-registry/a-1.0.01.tgz#abc123"
integrity sha512-xyz
dependencies:
b "^2"
b@2.0.x:
version "2.0.1"
b@^2:
version "2.0.0"
`;
const mockModern = `${MODERN_HEADER}
a@^1:
version: 1.0.1
dependencies:
b: ^2
integrity: sha512-xyz
resolved: "https://my-registry/a-1.0.01.tgz#abc123"
"b@2.0.x, b@^2.0.1":
version: 2.0.1
b@^2:
version: 2.0.0
`;
describe('Lockfile', () => {
const mockDir = createMockDirectory();
it('should load and serialize a legacy lockfile', async () => {
mockDir.setContent({
'yarn.lock': mockLegacy,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockLegacy);
});
it('should load and serialize a modern lockfile', async () => {
mockDir.setContent({
'yarn.lock': mockModern,
});
const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock'));
expect(lockfile.get('a')).toEqual([
{ range: '^1', version: '1.0.1', dataKey: 'a@^1' },
]);
expect(lockfile.get('b')).toEqual([
{ range: '2.0.x', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2.0.1', version: '2.0.1', dataKey: 'b@2.0.x, b@^2.0.1' },
{ range: '^2', version: '2.0.0', dataKey: 'b@^2' },
]);
expect(lockfile.toString()).toBe(mockModern);
});
});
describe('Lockfile advanced', () => {
describe('diff', () => {
const lockfileLegacyA = Lockfile.parse(`${LEGACY_HEADER}
a@^1:
+26 -2
View File
@@ -14,12 +14,22 @@
* limitations under the License.
*/
import { parseSyml } from '@yarnpkg/parsers';
import { parseSyml, stringifySyml } from '@yarnpkg/parsers';
import { stringify as legacyStringifyLockfile } from '@yarnpkg/lockfile';
import crypto from 'node:crypto';
import fs from 'fs-extra';
const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/;
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-core/sources/Project.ts#L1741-L1746
const NEW_HEADER = `${[
`# This file is generated by running "yarn install" inside your project.\n`,
`# Manual changes might be lost - proceed with caution!\n`,
].join(``)}\n`;
// https://github.com/yarnpkg/berry/blob/0c5974f193a9397630e9aee2b3876cca62611149/packages/yarnpkg-parsers/sources/syml.ts#L136
const LEGACY_REGEX = /^(#.*(\r?\n))*?#\s+yarn\s+lockfile\s+v1\r?\n/i;
/** @internal */
type LockfileData = {
[entry: string]: {
@@ -97,6 +107,8 @@ export class Lockfile {
* @public
*/
static parse(content: string): Lockfile {
const legacy = LEGACY_REGEX.test(content);
let data: LockfileData;
try {
data = parseSyml(content);
@@ -130,18 +142,21 @@ export class Lockfile {
}
}
return new Lockfile(packages, data);
return new Lockfile(packages, data, legacy);
}
private readonly packages: Map<string, LockfileQueryEntry[]>;
private readonly data: LockfileData;
private readonly legacy: boolean;
private constructor(
packages: Map<string, LockfileQueryEntry[]>,
data: LockfileData,
legacy: boolean = false,
) {
this.packages = packages;
this.data = data;
this.legacy = legacy;
}
/** Returns the name of all packages available in the lockfile */
@@ -154,6 +169,15 @@ export class Lockfile {
return this.packages.keys();
}
/**
* Serialize the lockfile back to a string.
*/
toString(): string {
return this.legacy
? legacyStringifyLockfile(this.data)
: NEW_HEADER + stringifySyml(this.data);
}
/**
* Creates a simplified dependency graph from the lockfile data, where each
* key is a package, and the value is a set of all packages that it depends on
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { detectYarnVersion } from './yarn';
export { fetchPackageInfo, mapDependencies } from './packages';
export type { PkgVersionInfo, YarnInfoInspectData } from './packages';
@@ -0,0 +1,161 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as runObj from '@backstage/cli-common';
import * as yarn from './yarn';
import { fetchPackageInfo, mapDependencies } from './packages';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { NotFoundError } from '@backstage/errors';
jest.mock('@backstage/cli-common', () => {
const actual = jest.requireActual('@backstage/cli-common');
return {
...actual,
runOutput: jest.fn(),
};
});
jest.mock('./yarn', () => {
return {
detectYarnVersion: jest.fn(),
};
});
describe('fetchPackageInfo', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should forward info for yarn classic', async () => {
jest
.spyOn(runObj, 'runOutput')
.mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runOutput).toHaveBeenCalledWith([
'yarn',
'info',
'--json',
'my-package',
]);
});
it('should forward info for yarn berry', async () => {
jest.spyOn(runObj, 'runOutput').mockResolvedValue(`{"the":"data"}`);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runOutput).toHaveBeenCalledWith([
'yarn',
'npm',
'info',
'--json',
'my-package',
]);
});
it('should throw if no info with yarn classic', async () => {
jest.spyOn(runObj, 'runOutput').mockResolvedValue('');
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
it('should throw if no info with yarn berry', async () => {
const error = new Error('Command failed');
(error as Error & { stdout?: string }).stdout =
'bla bla bla Response Code: 404 bla bla';
jest.spyOn(runObj, 'runOutput').mockRejectedValue(error);
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
});
describe('mapDependencies', () => {
const mockDir = createMockDirectory();
afterEach(() => {
jest.resetAllMocks();
});
it('should read dependencies', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: {
packages: ['pkgs/*'],
},
}),
pkgs: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': '1 || 2',
},
}),
},
b: {
'package.json': JSON.stringify({
name: 'b',
dependencies: {
'@backstage/core': '3',
'@backstage/cli': '^0',
},
}),
},
},
});
const dependencyMap = await mapDependencies(mockDir.path, '@backstage/*');
expect(Array.from(dependencyMap)).toEqual([
[
'@backstage/core',
[
{
name: 'a',
range: '1 || 2',
location: mockDir.resolve('pkgs/a'),
},
{
name: 'b',
range: '3',
location: mockDir.resolve('pkgs/b'),
},
],
],
[
'@backstage/cli',
[
{
name: 'b',
range: '^0',
location: mockDir.resolve('pkgs/b'),
},
],
],
]);
});
});
@@ -0,0 +1,144 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { minimatch } from 'minimatch';
import { getPackages } from '@manypkg/get-packages';
import { detectYarnVersion } from './yarn';
import { runOutput } from '@backstage/cli-common';
import { NotFoundError } from '@backstage/errors';
const DEP_TYPES = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
] as const;
/**
* Package data as returned by `yarn info`.
*
* @public
*/
export type YarnInfoInspectData = {
name: string;
'dist-tags': Record<string, string>;
versions: string[];
time: { [version: string]: string };
};
// Possible `yarn info` output
type YarnInfo = {
type: 'inspect';
data: YarnInfoInspectData | { type: string; data: unknown };
};
/**
* Version information for a package dependency.
*
* @public
*/
export type PkgVersionInfo = {
range: string;
name: string;
location: string;
};
/**
* Fetches package information from the registry using `yarn info` or `yarn npm info`.
*
* @public
*/
export async function fetchPackageInfo(
name: string,
): Promise<YarnInfoInspectData> {
const yarnVersion = await detectYarnVersion();
const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info'];
try {
const output = await runOutput(['yarn', ...cmd, '--json', name]);
if (!output) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
if (yarnVersion === 'berry') {
return JSON.parse(output) as YarnInfoInspectData;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
} catch (error) {
if (yarnVersion === 'classic') {
throw error;
}
if (
error instanceof Error &&
'stdout' in error &&
typeof error.stdout === 'string' &&
error.stdout.includes('Response Code: 404')
) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
throw error;
}
}
/**
* Map all dependencies in the repo as dependency to dependents.
*
* @public
*/
export async function mapDependencies(
targetDir: string,
pattern: string,
): Promise<Map<string, PkgVersionInfo[]>> {
const { packages, root } = await getPackages(targetDir);
// Include root package.json too
packages.push(root);
const dependencyMap = new Map<string, PkgVersionInfo[]>();
for (const pkg of packages) {
const deps = DEP_TYPES.flatMap(
t => Object.entries(pkg.packageJson[t] ?? {}) as [string, string][],
);
for (const [name, range] of deps) {
if (minimatch(name, pattern)) {
dependencyMap.set(
name,
(dependencyMap.get(name) ?? []).concat({
range,
name: pkg.packageJson.name,
location: pkg.dir,
}),
);
}
}
}
return dependencyMap;
}
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { assertError, ForwardedError } from '@backstage/errors';
import { runOutput } from '@backstage/cli-common';
const versions = new Map<string, Promise<'classic' | 'berry'>>();
/**
* Detects the version of Yarn in use.
*
* @public
*/
export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> {
const cwd = dir ?? process.cwd();
if (versions.has(cwd)) {
return versions.get(cwd)!;
}
const promise = Promise.resolve().then(async () => {
try {
const stdout = await runOutput(['yarn', '--version'], {
cwd,
});
return stdout.trim().startsWith('1.') ? 'classic' : 'berry';
} catch (error) {
assertError(error);
throw new ForwardedError('Failed to determine yarn version', error);
}
});
versions.set(cwd, promise);
return promise;
}