diff --git a/packages/cli/package.json b/packages/cli/package.json index e1adc8f0e2..7f1c27f27c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/lib/monorepo/PackageGraph.test.ts b/packages/cli/src/lib/monorepo/PackageGraph.test.ts new file mode 100644 index 0000000000..e8e531bc93 --- /dev/null +++ b/packages/cli/src/lib/monorepo/PackageGraph.test.ts @@ -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`, + ); + }); +}); diff --git a/packages/cli/src/lib/monorepo/PackageGraph.ts b/packages/cli/src/lib/monorepo/PackageGraph.ts new file mode 100644 index 0000000000..557b942405 --- /dev/null +++ b/packages/cli/src/lib/monorepo/PackageGraph.ts @@ -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; + /** All direct local dependencies that will be present in the published package */ + publishedLocalDependencies: Map; + /** Local dependencies */ + localDependencies: Map; + /** Local devDependencies */ + localDevDependencies: Map; + /** Local optionalDependencies */ + localOptionalDependencies: Map; +}; + +export class PackageGraph extends Map { + 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 | undefined, + ): Set { + const targets = new Set(); + 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; + } +} diff --git a/packages/cli/src/lib/monorepo/index.ts b/packages/cli/src/lib/monorepo/index.ts new file mode 100644 index 0000000000..aa12c63abc --- /dev/null +++ b/packages/cli/src/lib/monorepo/index.ts @@ -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'; diff --git a/yarn.lock b/yarn.lock index e409fba60a..f17b271f55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"