Skip to content
Merged
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
22 changes: 18 additions & 4 deletions apps/heft/src/configuration/HeftPluginConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { IHeftConfigurationJsonPluginSpecifier } from '../utilities/CoreCon
import heftPluginSchema from '../schemas/heft-plugin.schema.json';

export interface IHeftPluginConfigurationJson {
defaultPluginName?: string;
lifecyclePlugins?: IHeftLifecyclePluginDefinitionJson[];
taskPlugins?: IHeftTaskPluginDefinitionJson[];
}
Expand Down Expand Up @@ -85,7 +86,9 @@ export class HeftPluginConfiguration {
public getPluginDefinitionBySpecifier(
pluginSpecifier: IHeftConfigurationJsonPluginSpecifier
): HeftPluginDefinitionBase {
if (!pluginSpecifier.pluginName) {
const pluginName: string | undefined =
pluginSpecifier.pluginName || this.#heftPluginConfigurationJson.defaultPluginName;
if (!pluginName) {
const pluginDefinitions: HeftPluginDefinitionBase[] = ([] as HeftPluginDefinitionBase[]).concat(
Array.from(this.#getLifecyclePluginDefinitions()),
Array.from(this.#getTaskPluginDefinitions())
Expand All @@ -101,12 +104,12 @@ export class HeftPluginConfiguration {
} else {
// Try resolving to a lifecycle plugin first
const pluginDefinition: HeftPluginDefinitionBase | undefined =
this.tryGetLifecyclePluginDefinitionByName(pluginSpecifier.pluginName) ||
this.tryGetTaskPluginDefinitionByName(pluginSpecifier.pluginName);
this.tryGetLifecyclePluginDefinitionByName(pluginName) ||
this.tryGetTaskPluginDefinitionByName(pluginName);
if (!pluginDefinition) {
throw new Error(
`The specified plugin package ${JSON.stringify(pluginSpecifier.pluginPackage)} does not contain ` +
`a plugin named ${JSON.stringify(pluginSpecifier.pluginName)}.`
`a plugin named ${JSON.stringify(pluginName)}.`
);
}
return pluginDefinition;
Expand Down Expand Up @@ -229,5 +232,16 @@ export class HeftPluginConfiguration {
}
taskPluginNames.add(taskPluginDefinitionJson.pluginName);
}

const { defaultPluginName } = heftPluginConfigurationJson;
if (
defaultPluginName &&
!taskPluginNames.has(defaultPluginName) &&
!lifecyclePluginNames.has(defaultPluginName)
) {
throw new Error(
`Unknown defaultPluginName ${JSON.stringify(defaultPluginName)} in package ${JSON.stringify(packageName)}.`
);
}
}
}
100 changes: 100 additions & 0 deletions apps/heft/src/configuration/test/HeftPluginConfiguration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import * as fs from 'node:fs/promises';
import * as path from 'node:path';

import { HeftPluginConfiguration, type IHeftPluginConfigurationJson } from '../HeftPluginConfiguration';

describe('HeftPluginConfiguration', () => {
let folder: string;

beforeEach(async () => {
folder = await fs.mkdtemp(path.join(__dirname, 'plugin-config-'));
});

afterEach(async () => {
await fs.rm(folder, { recursive: true, force: true });
});

async function loadAsync(config: IHeftPluginConfigurationJson): Promise<HeftPluginConfiguration> {
await fs.writeFile(path.join(folder, 'heft-plugin.json'), JSON.stringify(config));
return await HeftPluginConfiguration.loadFromPackageAsync(folder, 'fixture');
}

describe.each(['taskPlugins', 'lifecyclePlugins'] as const)('%s', (pluginType) => {
const plugins = [
{ pluginName: 'original', entryPoint: './original.js' },
{ pluginName: 'adapter', entryPoint: './adapter.js' }
];

it('selects the declared default rather than the first plugin', async () => {
const config = await loadAsync({ defaultPluginName: 'adapter', [pluginType]: plugins });
expect(
config.getPluginDefinitionBySpecifier({ pluginPackage: 'fixture', pluginPackageRoot: folder })
.pluginName
).toBe('adapter');
});

it('lets an explicit pluginName override the default', async () => {
const config = await loadAsync({ defaultPluginName: 'original', [pluginType]: plugins });
expect(
config.getPluginDefinitionBySpecifier({
pluginPackage: 'fixture',
pluginPackageRoot: folder,
pluginName: 'adapter'
}).pluginName
).toBe('adapter');
});

it('rejects an unknown explicit pluginName instead of using the default', async () => {
const config = await loadAsync({ defaultPluginName: 'original', [pluginType]: plugins });
expect(() =>
config.getPluginDefinitionBySpecifier({
pluginPackage: 'fixture',
pluginPackageRoot: folder,
pluginName: 'missing'
})
).toThrow('does not contain a plugin named "missing"');
});

it('requires a pluginName for multiple plugins without a default', async () => {
const config = await loadAsync({ [pluginType]: plugins });
expect(() =>
config.getPluginDefinitionBySpecifier({ pluginPackage: 'fixture', pluginPackageRoot: folder })
).toThrow(/multiple plugins/);
});

it('preserves existing single-plugin selection', async () => {
const config = await loadAsync({ [pluginType]: plugins.slice(0, 1) });
expect(
config.getPluginDefinitionBySpecifier({ pluginPackage: 'fixture', pluginPackageRoot: folder })
.pluginName
).toBe('original');
});

it('rejects an unknown default when loading the package', async () => {
await expect(loadAsync({ defaultPluginName: 'missing', [pluginType]: plugins })).rejects.toThrow(
'Unknown defaultPluginName "missing" in package "fixture".'
);
});

it('rejects an empty defaultPluginName', async () => {
await expect(loadAsync({ defaultPluginName: '', [pluginType]: plugins })).rejects.toThrow(
/defaultPluginName/
);
});
});

it.each(['task', 'lifecycle'])('selects a %s default in a mixed package', async (defaultPluginName) => {
const config = await loadAsync({
defaultPluginName,
taskPlugins: [{ pluginName: 'task', entryPoint: './task.js' }],
lifecyclePlugins: [{ pluginName: 'lifecycle', entryPoint: './lifecycle.js' }]
});
expect(
config.getPluginDefinitionBySpecifier({ pluginPackage: 'fixture', pluginPackageRoot: folder })
.pluginName
).toBe(defaultPluginName);
});
});
6 changes: 6 additions & 0 deletions apps/heft/src/schemas/heft-plugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,12 @@
"type": "string"
},

"defaultPluginName": {
"description": "The explicitly selected plugin when heft.json omits pluginName. Must name a plugin declared in this file. Without this field, packages with multiple plugins require pluginName.",
"type": "string",
"minLength": 1
},

"lifecyclePlugins": {
"title": "Lifecycle Plugins",
"description": "A list of plugins that will be loaded and used for Heft lifecycle hooks.",
Expand Down
2 changes: 1 addition & 1 deletion apps/heft/src/schemas/heft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"pattern": "[^\\\\]"
},
"pluginName": {
"description": "Name of the plugin to load from the specified package. If not specified and the plugin package provides a single plugin, that plugin will be loaded.",
"description": "Name of the plugin to load from the specified package. If omitted, the package's defaultPluginName from heft-plugin.json is used. Without a default, the package must provide a single plugin.",
"type": "string",
"pattern": "^[a-z][a-z0-9]*([-][a-z0-9]+)*$"
},
Expand Down
8 changes: 4 additions & 4 deletions apps/heft/src/schemas/templates/heft.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@
//
// /**
// * The name of the plugin to load from the NPM package's heft-plugin.json manifest.
// * If not specified, and if the plugin package provides a single plugin, then that
// * plugin will be loaded.
// * If omitted, the manifest's defaultPluginName is used. Without a default,
// * the package must provide a single plugin.
// */
// // "pluginName": "example-plugin",
//
Expand Down Expand Up @@ -156,8 +156,8 @@

/**
* The name of the plugin to load from the NPM package's heft-plugin.json manifest.
* If not specified, and if the plugin package provides a single plugin, then that
* plugin will be loaded.
* If omitted, the manifest's defaultPluginName is used. Without a default,
* the package must provide a single plugin.
*/
// "pluginName": "example-plugin",

Expand Down
10 changes: 10 additions & 0 deletions common/changes/@rushstack/heft/default-plugin-name_2026-09-14.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/heft",
"comment": "Support an explicit defaultPluginName in heft-plugin.json so a package can add named plugins without breaking existing unnamed plugin references.",
"type": "minor"
}
],
"packageName": "@rushstack/heft"
}
Loading