cli: add PackagesGraph for traversing monorepo package graphs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-01-07 11:35:13 +01:00
parent c6c571dff0
commit 4aa68bf8f1
5 changed files with 291 additions and 0 deletions
+1
View File
@@ -36,6 +36,7 @@
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^4.0.0",
"@lerna/project": "^4.0.0",
"@manypkg/get-packages": "^1.1.3",
"@octokit/request": "^5.4.12",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-json": "^4.1.0",
@@ -0,0 +1,127 @@
/*
* 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 { getPackages } from '@manypkg/get-packages';
import { paths } from '../paths';
import { PackageGraph } from './PackageGraph';
const testPackages = [
{
dir: '/packages/a',
packageJson: {
name: 'a',
version: '1.0.0',
dependencies: {
b: '1.0.0',
},
devDependencies: {
c: '1.0.0',
},
},
},
{
dir: '/packages/b',
packageJson: {
name: 'b',
version: '1.0.0',
devDependencies: {
c: '1.0.0',
},
},
},
{
dir: '/packages/c',
packageJson: {
name: 'c',
version: '1.0.0',
},
},
];
describe('PackageGraph', () => {
it('is able to construct a graph from this repo', async () => {
const { packages } = await getPackages(paths.ownDir);
const graph = PackageGraph.fromPackages(packages);
expect(graph.has('@backstage/cli')).toBe(true);
});
it('creates a graph', () => {
const graph = PackageGraph.fromPackages(testPackages);
const a = graph.get('a');
const b = graph.get('b');
const c = graph.get('c');
expect(a).toMatchObject({
name: 'a',
dir: '/packages/a',
allLocalDependencies: new Map([
['b', b],
['c', c],
]),
publishedLocalDependencies: new Map([['b', b]]),
localDependencies: new Map([['b', b]]),
localDevDependencies: new Map([['c', c]]),
localOptionalDependencies: new Map(),
});
expect(b).toMatchObject({
name: 'b',
dir: '/packages/b',
allLocalDependencies: new Map([['c', c]]),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map([['c', c]]),
localOptionalDependencies: new Map(),
});
expect(c).toMatchObject({
name: 'c',
dir: '/packages/c',
allLocalDependencies: new Map(),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map(),
localOptionalDependencies: new Map(),
});
});
it('collects package names', () => {
const graph = PackageGraph.fromPackages(testPackages);
expect(graph.collectPackageNames(['a'], () => undefined)).toEqual(
new Set(['a']),
);
expect(
graph.collectPackageNames(['a'], pkg => pkg.localDependencies.keys()),
).toEqual(new Set(['a', 'b']));
expect(
graph.collectPackageNames(['a'], pkg => pkg.localDevDependencies.keys()),
).toEqual(new Set(['a', 'c']));
expect(
graph.collectPackageNames(['b', 'a'], pkg =>
pkg.localDevDependencies.keys(),
),
).toEqual(new Set(['b', 'a', 'c']));
// Should not get stuck in cycles
expect(graph.collectPackageNames(['a'], () => ['a', 'b', 'c'])).toEqual(
new Set(['b', 'a', 'c']),
);
// Throws on unknown packages
expect(() => graph.collectPackageNames(['a'], () => ['unknown'])).toThrow(
`Package 'unknown' not found`,
);
});
});
@@ -0,0 +1,133 @@
/*
* 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 { Package } from '@manypkg/get-packages';
export type PackageGraphNode = {
/** The name of the package */
name: string;
/** The directory of the package */
dir: string;
/** The package data of the package itself */
packageJson: Package['packageJson'];
/** All direct local dependencies of the package */
allLocalDependencies: Map<string, PackageGraphNode>;
/** All direct local dependencies that will be present in the published package */
publishedLocalDependencies: Map<string, PackageGraphNode>;
/** Local dependencies */
localDependencies: Map<string, PackageGraphNode>;
/** Local devDependencies */
localDevDependencies: Map<string, PackageGraphNode>;
/** Local optionalDependencies */
localOptionalDependencies: Map<string, PackageGraphNode>;
};
export class PackageGraph extends Map<string, PackageGraphNode> {
static fromPackages(packages: Package[]): PackageGraph {
const graph = new PackageGraph();
// Add all local packages to the graph
for (const pkg of packages) {
const name = pkg.packageJson.name;
const existingPkg = graph.get(name);
if (existingPkg) {
throw new Error(
`Duplicate package name '${name}' at ${pkg.dir} and ${existingPkg.dir}`,
);
}
graph.set(name, {
...pkg,
name,
allLocalDependencies: new Map(),
publishedLocalDependencies: new Map(),
localDependencies: new Map(),
localDevDependencies: new Map(),
localOptionalDependencies: new Map(),
});
}
// Populate the local dependency structure
for (const node of graph.values()) {
for (const depName of Object.keys(node.packageJson.dependencies || {})) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.publishedLocalDependencies.set(depName, depPkg);
node.localDependencies.set(depName, depPkg);
}
}
for (const depName of Object.keys(
node.packageJson.devDependencies || {},
)) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.localDevDependencies.set(depName, depPkg);
}
}
for (const depName of Object.keys(
node.packageJson.optionalDependencies || {},
)) {
const depPkg = graph.get(depName);
if (depPkg) {
node.allLocalDependencies.set(depName, depPkg);
node.publishedLocalDependencies.set(depName, depPkg);
node.localOptionalDependencies.set(depName, depPkg);
}
}
}
return graph;
}
/**
* Traverses the package graph and collects a set of package names.
*
* The traversal starts at the provided list names, and continues
* throughout all the names returned by the `collectFn`, which is
* called once for each seen package.
*/
collectPackageNames(
startingPackageNames: string[],
collectFn: (pkg: PackageGraphNode) => Iterable<string> | undefined,
): Set<string> {
const targets = new Set<string>();
const searchNames = startingPackageNames.slice();
while (searchNames.length) {
const name = searchNames.pop()!;
if (targets.has(name)) {
continue;
}
const node = this.get(name);
if (!node) {
throw new Error(`Package '${name}' not found`);
}
targets.add(name);
const collected = collectFn(node);
if (collected) {
searchNames.push(...collected);
}
}
return targets;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { PackageGraph } from './PackageGraph';
export type { PackageGraphNode } from './PackageGraph';
+12
View File
@@ -4532,6 +4532,18 @@
globby "^11.0.0"
read-yaml-file "^1.1.0"
"@manypkg/get-packages@^1.1.3":
version "1.1.3"
resolved "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz#e184db9bba792fa4693de4658cfb1463ac2c9c47"
integrity sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==
dependencies:
"@babel/runtime" "^7.5.5"
"@changesets/types" "^4.0.1"
"@manypkg/find-root" "^1.1.0"
fs-extra "^8.1.0"
globby "^11.0.0"
read-yaml-file "^1.1.0"
"@mapbox/node-pre-gyp@^1.0.0":
version "1.0.5"
resolved "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950"