From 9df64080f466fa55d36288ea7f3a5ab418533d86 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:31:56 +0000 Subject: [PATCH] fix(@angular/cli): include builder options for build and test in json-help Allow ArchitectCommandModule subclasses to declare multiple builders so that --json-help resolves and outputs each builder's schema options separately under the builders field. Configure BuildCommandModule with the Application (@angular/build:application) and Library (@angular/build:library) builders, and TestCommandModule with the Unit-test (@angular/build:unit-test) and Karma (@angular/build:karma) builders. --- .../architect-base-command-module.ts | 5 + .../architect-command-module.ts | 28 +++ .../command-builder/utilities/json-help.ts | 67 ++++++- .../utilities/json-help_spec.ts | 170 ++++++++++++++++++ .../angular/cli/src/commands/build/cli.ts | 4 + .../src/commands/build/long-description.md | 3 +- packages/angular/cli/src/commands/test/cli.ts | 4 + 7 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 packages/angular/cli/src/command-builder/utilities/json-help_spec.ts diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts index fb3508777d74..e30039f67fec 100644 --- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -183,6 +183,11 @@ export abstract class ArchitectBaseCommandModule return []; } + return this.getOptionsForBuilder(builderConf); + } + + protected async getOptionsForBuilder(builderConf: string): Promise { + const architectHost = this.getArchitectHost(); let builderDesc: NodeModulesBuilderInfo; try { builderDesc = await architectHost.resolveBuilder(builderConf); diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts index 640e22aea171..632241359528 100644 --- a/packages/angular/cli/src/command-builder/architect-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -18,6 +18,11 @@ import { Options, OtherOptions, } from './command-module'; +import { + JsonHelpBuilder, + schemaOptionsToJsonHelpOptions, + setJsonHelpBuilders, +} from './utilities/json-help'; export interface ArchitectCommandArgs { configuration?: string; @@ -30,6 +35,16 @@ export abstract class ArchitectCommandModule { abstract readonly multiTarget: boolean; + /** + * A mapping of human-readable builder names to their corresponding Architect builder + * identifiers (for example, `{ Application: '@angular/build:application' }`). + * + * When defined, `--json-help` resolves the schema options for each builder and includes + * them under the `builders` property of the JSON help output instead of flattening + * a single project target's builder options into the command's top-level options. + */ + readonly builders?: Readonly>; + findDefaultBuilderName?( project: workspaces.ProjectDefinition, target: Target, @@ -92,6 +107,19 @@ export abstract class ArchitectCommandModule }) .strict(); + if (jsonHelp && this.builders) { + const buildersHelp: JsonHelpBuilder[] = await Promise.all( + Object.entries(this.builders).map(async ([name, builderName]) => ({ + name, + options: schemaOptionsToJsonHelpOptions(await this.getOptionsForBuilder(builderName)), + })), + ); + + setJsonHelpBuilders(this.context.yargsInstance, buildersHelp); + + return localYargs; + } + if (!project) { return localYargs; } diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts index 0d5c6a53a1e6..f77c35d3ed9d 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-help.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts @@ -6,21 +6,31 @@ * found in the LICENSE file at https://angular.dev/license */ +import { strings } from '@angular-devkit/core'; import { Argv } from 'yargs'; import { FullDescribe } from '../command-module'; +import { Option } from './json-schema'; -interface JsonHelpOption { +export interface JsonHelpOption { name: string; type?: string; - deprecated: boolean | string; + deprecated?: boolean | string; aliases?: string[]; - default?: string; + default?: string | number | boolean | unknown[]; required?: boolean; positional?: number; - enum?: string[]; + enum?: (string | number | boolean)[]; description?: string; } +/** + * Represents a builder and its associated CLI options in JSON help output. + */ +export interface JsonHelpBuilder { + name: string; + options: JsonHelpOption[]; +} + interface JsonHelpDescription { shortDescription?: string; longDescription?: string; @@ -37,9 +47,56 @@ export interface JsonHelp extends JsonHelpDescription { name: string; command: string; options: JsonHelpOption[]; + builders?: JsonHelpBuilder[]; subcommands?: JsonHelpSubcommand[]; } +const jsonHelpBuilders = new WeakMap(); + +/** + * Associates a list of builder option groups with a yargs instance for `--json-help` serialization. + */ +export function setJsonHelpBuilders(localYargs: Argv, builders: JsonHelpBuilder[]): void { + jsonHelpBuilders.set(localYargs, builders); +} + +/** + * Converts parsed JSON schema options into normalized and sorted `JsonHelpOption` entries. + */ +export function schemaOptionsToJsonHelpOptions(options: Option[]): JsonHelpOption[] { + const normalizeOptions: JsonHelpOption[] = []; + + for (const option of options) { + if (option.hidden) { + continue; + } + + let dashedName = strings.dasherize(option.name); + if (option.type === 'boolean' && dashedName.startsWith('no-')) { + dashedName = dashedName.slice(3); + } + + const alias = Array.isArray(option.alias) + ? option.alias + : option.alias + ? [option.alias] + : undefined; + + normalizeOptions.push({ + name: dashedName, + type: option.type, + deprecated: option.deprecated, + aliases: alias && alias.length > 0 ? alias : undefined, + default: option.default as JsonHelpOption['default'], + enum: option.choices?.length ? (option.choices as JsonHelpOption['enum']) : undefined, + description: option.description, + positional: option.positional, + }); + } + + return normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)); +} + const yargsDefaultCommandRegExp = /^\$0|\*/; export function jsonHelpUsage(localYargs: Argv): string { @@ -123,12 +180,14 @@ export function jsonHelpUsage(localYargs: Argv): string { const [command, rawDescription] = usageInstance.getUsage()[0] ?? []; const defaultSubCommand = subcommands.find((x) => x.default)?.command ?? ''; const otherSubcommands = subcommands.filter((s) => !s.default); + const builders = jsonHelpBuilders.get(localYargs); const output: JsonHelp = { name: [...context.commands].pop(), command: `${command?.replace(yargsDefaultCommandRegExp, localYargsInstance['$0'])}${defaultSubCommand}`, ...parseDescription(rawDescription), options: normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)), + builders: builders?.length ? builders : undefined, subcommands: otherSubcommands.length ? otherSubcommands : undefined, }; diff --git a/packages/angular/cli/src/command-builder/utilities/json-help_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-help_spec.ts new file mode 100644 index 000000000000..429cbb533124 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-help_spec.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import yargs from 'yargs'; +import { jsonHelpUsage, schemaOptionsToJsonHelpOptions, setJsonHelpBuilders } from './json-help'; +import { Option } from './json-schema'; + +describe('json-help utilities', () => { + describe('schemaOptionsToJsonHelpOptions', () => { + it('normalizes and sorts schema options and filters out hidden options', () => { + const options: Option[] = [ + { + name: 'watch', + type: 'boolean', + default: false, + description: 'Run build when files change.', + alias: [], + }, + { + name: 'noProgress', + type: 'boolean', + description: 'Disable progress.', + alias: ['p'], + }, + { + name: 'hiddenOption', + type: 'string', + hidden: true, + description: 'Should be filtered out.', + alias: [], + }, + { + name: 'compilationMode', + type: 'string', + choices: ['full', 'partial'], + description: 'Compilation mode.', + alias: [], + }, + { + name: 'noPrefixString', + type: 'string', + alias: 'n', + deprecated: 'Use another option.', + description: 'Non-boolean option starting with no-.', + }, + ]; + + expect(schemaOptionsToJsonHelpOptions(options)).toEqual([ + { + name: 'compilation-mode', + type: 'string', + deprecated: undefined, + aliases: undefined, + default: undefined, + enum: ['full', 'partial'], + description: 'Compilation mode.', + positional: undefined, + }, + { + name: 'no-prefix-string', + type: 'string', + deprecated: 'Use another option.', + aliases: ['n'], + default: undefined, + enum: undefined, + description: 'Non-boolean option starting with no-.', + positional: undefined, + }, + { + name: 'progress', + type: 'boolean', + deprecated: undefined, + aliases: ['p'], + default: undefined, + enum: undefined, + description: 'Disable progress.', + positional: undefined, + }, + { + name: 'watch', + type: 'boolean', + deprecated: undefined, + aliases: undefined, + default: false, + enum: undefined, + description: 'Run build when files change.', + positional: undefined, + }, + ]); + }); + }); + + describe('setJsonHelpBuilders and jsonHelpUsage', () => { + it('includes builders in the jsonHelpUsage output when configured', () => { + const localYargs = yargs(['build', '--help']) + .scriptName('ng') + .usage('$0 build [project]', 'Build command') + .option('configuration', { + type: 'string', + alias: 'c', + describe: 'Builder configuration', + }); + + setJsonHelpBuilders(localYargs, [ + { + name: 'Application', + options: [ + { + name: 'aot', + type: 'boolean', + default: true, + description: 'Build using Ahead of Time compilation.', + }, + ], + }, + { + name: 'Library', + options: [ + { + name: 'compilation-mode', + type: 'string', + enum: ['full', 'partial'], + description: 'Angular compilation mode.', + }, + ], + }, + ]); + + const parsed = JSON.parse(jsonHelpUsage(localYargs)); + expect(parsed.builders).toEqual([ + { + name: 'Application', + options: [ + { + name: 'aot', + type: 'boolean', + default: true, + description: 'Build using Ahead of Time compilation.', + }, + ], + }, + { + name: 'Library', + options: [ + { + name: 'compilation-mode', + type: 'string', + enum: ['full', 'partial'], + description: 'Angular compilation mode.', + }, + ], + }, + ]); + }); + + it('omits builders when setJsonHelpBuilders is not called', () => { + const localYargs = yargs(['version', '--help']) + .scriptName('ng') + .usage('$0 version', 'Version command'); + + const parsed = JSON.parse(jsonHelpUsage(localYargs)); + expect(parsed.builders).toBeUndefined(); + }); + }); +}); diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts index c722f70709e3..6e86fc19fac9 100644 --- a/packages/angular/cli/src/commands/build/cli.ts +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -16,6 +16,10 @@ export default class BuildCommandModule implements CommandModuleImplementation { multiTarget = false; + override readonly builders = { + Application: '@angular/build:application', + Library: '@angular/build:library', + }; command = 'build [project]'; aliases = RootCommands['build'].aliases; describe = diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md index cba16adeb716..7c15ee534bcc 100644 --- a/packages/angular/cli/src/commands/build/long-description.md +++ b/packages/angular/cli/src/commands/build/long-description.md @@ -1,6 +1,5 @@ The command can be used to build a project of type "application" or "library". -When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied. -All other options apply only to building applications. +Depending on the project type, the CLI invokes either the application builder (`@angular/build:application`) or the library builder (`@angular/build:library`), each supporting its own set of options. The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. A "development" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration development`. diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts index c08789714c1c..adb38b2e9102 100644 --- a/packages/angular/cli/src/commands/test/cli.ts +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -16,6 +16,10 @@ export default class TestCommandModule implements CommandModuleImplementation { multiTarget = true; + override readonly builders = { + 'Unit-test': '@angular/build:unit-test', + 'Karma': '@angular/build:karma', + }; command = 'test [project]'; aliases = RootCommands['test'].aliases; describe = 'Runs unit tests in a project.';