Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions src/commands/export-dynamic-plugin/check-heavy-deps.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
92 changes: 92 additions & 0 deletions src/commands/export-dynamic-plugin/check-heavy-deps.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'@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<string, string> = {
'@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<HeavyDepKind, Record<string, string>> = {
backend: HEAVY_BACKEND_DEPS,
frontend: HEAVY_FRONTEND_DEPS,
};

export async function checkHeavyDependencies(
targetPath: string,
strict: boolean,
kind: HeavyDepKind,
): Promise<void> {
const blocklist = HEAVY_DEPS_BY_KIND[kind];
const packageJsonPath = path.join(targetPath, 'package.json');
const targetPackage = await fs.readJson(packageJsonPath);
const dependencies: Record<string, string> = 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.`,
);
}
}
11 changes: 11 additions & 0 deletions src/commands/export-dynamic-plugin/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -75,6 +76,16 @@ export async function command(opts: OptionValues): Promise<void> {
});
}

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);
Expand Down
5 changes: 5 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down
Loading