From 637e906375b5a6d425fe8e12085f8d8271e39fae Mon Sep 17 00:00:00 2001 From: Frank Kong Date: Mon, 24 Aug 2026 09:37:15 -0400 Subject: [PATCH] chore: add heavy dep check Signed-off-by: Frank Kong rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- .../check-heavy-deps.test.ts | 187 ++++++++++++++++++ .../export-dynamic-plugin/check-heavy-deps.ts | 92 +++++++++ src/commands/export-dynamic-plugin/command.ts | 11 ++ src/commands/index.ts | 5 + 4 files changed, 295 insertions(+) create mode 100644 src/commands/export-dynamic-plugin/check-heavy-deps.test.ts create mode 100644 src/commands/export-dynamic-plugin/check-heavy-deps.ts diff --git a/src/commands/export-dynamic-plugin/check-heavy-deps.test.ts b/src/commands/export-dynamic-plugin/check-heavy-deps.test.ts new file mode 100644 index 0000000..cf5cb5b --- /dev/null +++ b/src/commands/export-dynamic-plugin/check-heavy-deps.test.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2026 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 mockFs from 'mock-fs'; + +import { Task } from '../../lib/tasks'; +import { checkHeavyDependencies } from './check-heavy-deps'; + +describe('checkHeavyDependencies', () => { + const targetPath = '/tmp/dist-dynamic'; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest.spyOn(Task, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + mockFs.restore(); + logSpy.mockRestore(); + }); + + describe('backend', () => { + it('does not warn when no heavy deps are present', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-plugin-api': '1.0.0', + '@backstage/plugin-auth-node': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns for each heavy dependency in production dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-defaults': '1.0.0', + '@backstage/backend-app-api': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).toHaveBeenCalledTimes(2); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-defaults', + ); + expect(logSpy.mock.calls[1][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-app-api', + ); + expect(logSpy.mock.calls[0][0]).toContain( + 'Should not be used in backend plugins', + ); + expect(logSpy.mock.calls[0][0]).not.toMatch(/~\d+/); + }); + + it('throws in strict mode after logging all violations', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-test-utils': '1.0.0', + }, + }), + }, + }); + + await expect( + checkHeavyDependencies(targetPath, true, 'backend'), + ).rejects.toThrow( + 'Found 1 disallowed dependency in production dependencies', + ); + expect(logSpy).toHaveBeenCalledTimes(1); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/backend-test-utils', + ); + }); + + it('only checks production dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/backend-plugin-api': '1.0.0', + }, + devDependencies: { + '@backstage/backend-test-utils': '1.0.0', + }, + peerDependencies: { + '@backstage/backend-defaults': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'backend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('frontend', () => { + it('does not warn when no heavy deps are present', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/frontend-plugin-api': '1.0.0', + '@backstage/core-components': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('warns for app-level and dev/test frontend dependencies', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/core-app-api': '1.0.0', + '@backstage/frontend-defaults': '1.0.0', + '@backstage/frontend-test-utils': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).toHaveBeenCalledTimes(3); + expect(logSpy.mock.calls[0][0]).toContain( + 'WARNING: Found heavy dependency @backstage/core-app-api', + ); + expect(logSpy.mock.calls[1][0]).toContain( + 'WARNING: Found heavy dependency @backstage/frontend-defaults', + ); + expect(logSpy.mock.calls[2][0]).toContain( + 'WARNING: Found heavy dependency @backstage/frontend-test-utils', + ); + }); + + it('does not flag allowed plugin API packages', async () => { + mockFs({ + [targetPath]: { + 'package.json': JSON.stringify({ + dependencies: { + '@backstage/core-plugin-api': '1.0.0', + '@backstage/frontend-plugin-api': '1.0.0', + }, + }), + }, + }); + + await checkHeavyDependencies(targetPath, false, 'frontend'); + + expect(logSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/export-dynamic-plugin/check-heavy-deps.ts b/src/commands/export-dynamic-plugin/check-heavy-deps.ts new file mode 100644 index 0000000..69878c2 --- /dev/null +++ b/src/commands/export-dynamic-plugin/check-heavy-deps.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2026 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 chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'node:path'; + +import { Task } from '../../lib/tasks'; + +export const HEAVY_BACKEND_DEPS: Record = { + '@backstage/backend-defaults': + 'Should not be used in backend plugins. Use @backstage/backend-plugin-api instead.', + '@backstage/backend-app-api': + 'Should not be used in backend plugins. Use @backstage/backend-plugin-api instead.', + '@backstage/backend-test-utils': + 'Should not be used in production dependencies. Move to devDependencies.', + '@backstage/backend-dynamic-feature-service': + 'Should not be used in backend plugins. Use the -node variant instead.', +}; + +export const HEAVY_FRONTEND_DEPS: Record = { + '@backstage/core-app-api': + 'Should not be used in frontend plugins. Use @backstage/frontend-plugin-api or @backstage/core-plugin-api instead.', + '@backstage/frontend-app-api': + 'Should not be used in frontend plugins. Use @backstage/frontend-plugin-api instead.', + '@backstage/frontend-defaults': + 'App-level wiring only. Use in app packages or move to devDependencies.', + '@backstage/app-defaults': + 'App-level wiring only. Use in app packages or move to devDependencies.', + '@backstage/dev-utils': 'Dev server helper only. Move to devDependencies.', + '@backstage/frontend-dev-utils': + 'Dev server helper only. Move to devDependencies.', + '@backstage/frontend-test-utils': + 'Test utilities only. Move to devDependencies.', + '@backstage/test-utils': 'Test utilities only. Move to devDependencies.', + '@backstage/frontend-dynamic-feature-loader': + 'App-level dynamic feature loading. Should not be a plugin production dependency.', +}; + +export type HeavyDepKind = 'backend' | 'frontend'; + +const HEAVY_DEPS_BY_KIND: Record> = { + backend: HEAVY_BACKEND_DEPS, + frontend: HEAVY_FRONTEND_DEPS, +}; + +export async function checkHeavyDependencies( + targetPath: string, + strict: boolean, + kind: HeavyDepKind, +): Promise { + const blocklist = HEAVY_DEPS_BY_KIND[kind]; + const packageJsonPath = path.join(targetPath, 'package.json'); + const targetPackage = await fs.readJson(packageJsonPath); + const dependencies: Record = targetPackage.dependencies ?? {}; + + const violations = Object.keys(dependencies).filter(dep => dep in blocklist); + + if (violations.length === 0) { + return; + } + + for (const dep of violations) { + Task.log( + chalk.yellow( + [ + `WARNING: Found heavy dependency ${chalk.cyan(dep)} in production dependencies.`, + ` ${blocklist[dep]}`, + ].join('\n'), + ), + ); + } + + if (strict) { + throw new Error( + `Found ${violations.length} disallowed ${violations.length === 1 ? 'dependency' : 'dependencies'} in production dependencies. Remove ${violations.length === 1 ? 'it' : 'them'} or omit --strict-deps to export with warnings only.`, + ); + } +} diff --git a/src/commands/export-dynamic-plugin/command.ts b/src/commands/export-dynamic-plugin/command.ts index 74a9103..06bac89 100644 --- a/src/commands/export-dynamic-plugin/command.ts +++ b/src/commands/export-dynamic-plugin/command.ts @@ -27,6 +27,7 @@ import { paths } from '../../lib/paths'; import { getConfigSchema } from '../../lib/schema/collect'; import { Task } from '../../lib/tasks'; import { backend } from './backend'; +import { checkHeavyDependencies, HeavyDepKind } from './check-heavy-deps'; import { applyDevOptions } from './dev'; import { frontend } from './frontend'; @@ -75,6 +76,16 @@ export async function command(opts: OptionValues): Promise { }); } + const heavyDepKind: HeavyDepKind = + role === 'backend-plugin' || role === 'backend-plugin-module' + ? 'backend' + : 'frontend'; + await checkHeavyDependencies( + targetPath, + Boolean(opts.strictDeps), + heavyDepKind, + ); + await checkBackstageSupportedVersions(targetPath); await applyDevOptions(opts, rawPkg.name, roleInfo, targetPath); diff --git a/src/commands/index.ts b/src/commands/index.ts index 978160d..7d5b110 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -63,6 +63,11 @@ export function registerPluginCommand(program: Command) { '--clean', 'Remove the dynamic plugin output before exporting again.', ) + .option( + '--strict-deps', + 'Fail export when production dependencies include disallowed heavy packages. Use in CI to enforce dependency rules.', + false, + ) .option( '--dev', 'Allow testing/debugging a dynamic plugin locally. This creates a link from the dynamic plugin content to the plugin package `src` folder, to enable the use of source maps (backend plugin only). This also installs the dynamic plugin content (symlink) into the dynamic plugins root folder configured in the app config (or copies the plugin content to the location explicitely provided by the `--dynamic-plugins-root` argument).',