From 2f2055073025f88b59997b7af2e8b54013879dd9 Mon Sep 17 00:00:00 2001 From: Yashwanth Reddy Verupaka <247739100+yverupaka_microsoft@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:41:21 -0800 Subject: [PATCH] Support default plugin selection --- .../configuration/HeftPluginConfiguration.ts | 22 +++- .../test/HeftPluginConfiguration.test.ts | 100 ++++++++++++++++++ apps/heft/src/schemas/heft-plugin.schema.json | 6 ++ apps/heft/src/schemas/heft.schema.json | 2 +- apps/heft/src/schemas/templates/heft.json | 8 +- .../heft/default-plugin-name_2026-09-14.json | 10 ++ 6 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 apps/heft/src/configuration/test/HeftPluginConfiguration.test.ts create mode 100644 common/changes/@rushstack/heft/default-plugin-name_2026-09-14.json diff --git a/apps/heft/src/configuration/HeftPluginConfiguration.ts b/apps/heft/src/configuration/HeftPluginConfiguration.ts index ac2ab9334c4..50807b3750c 100644 --- a/apps/heft/src/configuration/HeftPluginConfiguration.ts +++ b/apps/heft/src/configuration/HeftPluginConfiguration.ts @@ -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[]; } @@ -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()) @@ -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; @@ -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)}.` + ); + } } } diff --git a/apps/heft/src/configuration/test/HeftPluginConfiguration.test.ts b/apps/heft/src/configuration/test/HeftPluginConfiguration.test.ts new file mode 100644 index 00000000000..545ad7ab303 --- /dev/null +++ b/apps/heft/src/configuration/test/HeftPluginConfiguration.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/heft/src/schemas/heft-plugin.schema.json b/apps/heft/src/schemas/heft-plugin.schema.json index 37dd45012d7..7755f3b9124 100644 --- a/apps/heft/src/schemas/heft-plugin.schema.json +++ b/apps/heft/src/schemas/heft-plugin.schema.json @@ -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.", diff --git a/apps/heft/src/schemas/heft.schema.json b/apps/heft/src/schemas/heft.schema.json index cba91630724..2a4244bb5d7 100644 --- a/apps/heft/src/schemas/heft.schema.json +++ b/apps/heft/src/schemas/heft.schema.json @@ -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]+)*$" }, diff --git a/apps/heft/src/schemas/templates/heft.json b/apps/heft/src/schemas/templates/heft.json index 870a1a538a3..25ab1ce31c9 100644 --- a/apps/heft/src/schemas/templates/heft.json +++ b/apps/heft/src/schemas/templates/heft.json @@ -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", // @@ -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", diff --git a/common/changes/@rushstack/heft/default-plugin-name_2026-09-14.json b/common/changes/@rushstack/heft/default-plugin-name_2026-09-14.json new file mode 100644 index 00000000000..2919acb9b49 --- /dev/null +++ b/common/changes/@rushstack/heft/default-plugin-name_2026-09-14.json @@ -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" +}