feat(dev-infra): introduce validators for ng-dev config loading (#37049)

Introduces infrastructure to validate configuration of the ng-dev
command at run time.  Allowing for errors to be returned to the
user running the command.

PR Close #37049
This commit is contained in:
Joey Perrott 2020-05-08 14:51:29 -07:00 committed by Misko Hevery
parent 45f4a47286
commit 14c0ec97d8
7 changed files with 119 additions and 46 deletions

View File

@ -5,9 +5,27 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {assertNoErrors, getConfig, NgDevConfig} from '../utils/config';
export interface CommitMessageConfig { export interface CommitMessageConfig {
maxLineLength: number; maxLineLength: number;
minBodyLength: number; minBodyLength: number;
types: string[]; types: string[];
scopes: string[]; scopes: string[];
} }
/** Retrieve and validate the config as `CommitMessageConfig`. */
export function getCommitMessageConfig() {
// List of errors encountered validating the config.
const errors: string[] = [];
// The unvalidated config object.
const config: Partial<NgDevConfig<{commitMessage: CommitMessageConfig}>> = getConfig();
if (config.commitMessage === undefined) {
errors.push(`No configuration defined for "commitMessage"`);
}
assertNoErrors(errors);
return config as Required<typeof config>;
}

View File

@ -7,11 +7,9 @@
*/ */
// Imports // Imports
import * as utilConfig from '../utils/config'; import * as validateConfig from './config';
import {validateCommitMessage} from './validate'; import {validateCommitMessage} from './validate';
// Constants // Constants
const config = { const config = {
'commitMessage': { 'commitMessage': {
@ -46,7 +44,7 @@ describe('validate-commit-message.js', () => {
lastError = ''; lastError = '';
spyOn(console, 'error').and.callFake((msg: string) => lastError = msg); spyOn(console, 'error').and.callFake((msg: string) => lastError = msg);
spyOn(utilConfig, 'getAngularDevConfig').and.returnValue(config); spyOn(validateConfig, 'getCommitMessageConfig').and.returnValue(config);
}); });
describe('validateMessage()', () => { describe('validateMessage()', () => {

View File

@ -5,8 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {getAngularDevConfig} from '../utils/config'; import {getCommitMessageConfig} from './config';
import {CommitMessageConfig} from './config';
/** Options for commit message validation. */ /** Options for commit message validation. */
export interface ValidateCommitMessageOptions { export interface ValidateCommitMessageOptions {
@ -76,7 +75,7 @@ export function validateCommitMessage(
`<type>(<scope>): <subject>\n\n<body>`); `<type>(<scope>): <subject>\n\n<body>`);
} }
const config = getAngularDevConfig<'commitMessage', CommitMessageConfig>().commitMessage; const config = getCommitMessageConfig().commitMessage;
const commit = parseCommitMessage(commitMsg); const commit = parseCommitMessage(commitMsg);
//////////////////////////////////// ////////////////////////////////////

View File

@ -6,8 +6,46 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
export interface FormatConfig { import {assertNoErrors, getConfig, NgDevConfig} from '../utils/config';
[keyof: string]: boolean|{
interface Formatter {
matchers: string[]; matchers: string[];
}; }
export interface FormatConfig {
[keyof: string]: boolean|Formatter;
}
/** Retrieve and validate the config as `FormatConfig`. */
export function getFormatConfig() {
// List of errors encountered validating the config.
const errors: string[] = [];
// The unvalidated config object.
const config: Partial<NgDevConfig<{format: FormatConfig}>> = getConfig();
if (config.format === undefined) {
errors.push(`No configuration defined for "format"`);
}
for (const [key, value] of Object.entries(config.format!)) {
switch (typeof value) {
case 'boolean':
break;
case 'object':
checkFormatterConfig(key, value, errors);
break;
default:
errors.push(`"format.${key}" is not a boolean or Formatter object`);
}
}
assertNoErrors(errors);
return config as Required<typeof config>;
}
/** Validate an individual Formatter config. */
function checkFormatterConfig(key: string, config: Partial<Formatter>, errors: string[]) {
if (config.matchers === undefined) {
errors.push(`Missing "format.${key}.matchers" value`);
}
} }

View File

@ -6,8 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {getAngularDevConfig} from '../../utils/config'; import {getFormatConfig} from '../config';
import {FormatConfig} from '../config';
import {Buildifier} from './buildifier'; import {Buildifier} from './buildifier';
import {ClangFormat} from './clang-format'; import {ClangFormat} from './clang-format';
@ -16,11 +15,7 @@ import {ClangFormat} from './clang-format';
* Get all defined formatters which are active based on the current loaded config. * Get all defined formatters which are active based on the current loaded config.
*/ */
export function getActiveFormatters() { export function getActiveFormatters() {
let config = {}; const config = getFormatConfig().format;
try {
config = getAngularDevConfig<'format', FormatConfig>().format || {};
} catch {
}
return [new Buildifier(config), new ClangFormat(config)].filter( return [new Buildifier(config), new ClangFormat(config)].filter(
formatter => formatter.isEnabled()); formatter => formatter.isEnabled());
} }

View File

@ -2,10 +2,7 @@ load("@npm_bazel_typescript//:index.bzl", "ts_library")
ts_library( ts_library(
name = "utils", name = "utils",
srcs = [ srcs = glob(["*.ts"]),
"config.ts",
"repo-files.ts",
],
module_name = "@angular/dev-infra-private/utils", module_name = "@angular/dev-infra-private/utils",
visibility = ["//dev-infra:__subpackages__"], visibility = ["//dev-infra:__subpackages__"],
deps = [ deps = [

View File

@ -9,13 +9,64 @@
import {join} from 'path'; import {join} from 'path';
import {exec} from 'shelljs'; import {exec} from 'shelljs';
/** The common configuration for ng-dev. */
type CommonConfig = {
// TODO: add common configuration
};
/**
* The configuration for the specific ng-dev command, providing both the common
* ng-dev config as well as the specific config of a subcommand.
*/
export type NgDevConfig<T = {}> = CommonConfig&T;
// The filename expected for creating the ng-dev config, without the file // The filename expected for creating the ng-dev config, without the file
// extension to allow either a typescript or javascript file to be used. // extension to allow either a typescript or javascript file to be used.
const CONFIG_FILE_NAME = '.ng-dev-config'; const CONFIG_FILE_NAME = '.ng-dev-config';
/** The configuration for ng-dev. */
let CONFIG: {}|null = null;
/** /**
* Gets the path of the directory for the repository base. * Get the configuration from the file system, returning the already loaded copy if it
* is defined.
*/ */
export function getConfig(): NgDevConfig {
// If the global config is not defined, load it from the file system.
if (CONFIG === null) {
// The full path to the configuration file.
const configPath = join(getRepoBaseDir(), CONFIG_FILE_NAME);
// Set the global config object to a clone of the configuration loaded through default exports
// from the config file.
CONFIG = {...require(configPath)};
}
// Return a clone of the global config to ensure that a new instance of the config is returned
// each time, preventing unexpected effects of modifications to the config object.
return validateCommonConfig({...CONFIG});
}
/** Validate the common configuration has been met for the ng-dev command. */
function validateCommonConfig(config: NgDevConfig<CommonConfig>) {
// TODO: add validation for the common configuration
return config;
}
/**
* Asserts the provided array of error messages is empty. If any errors are in the array,
* logs the errors and exit the process as a failure.
*/
export function assertNoErrors(errors: string[]) {
if (errors.length == 0) {
return;
}
console.error(`Errors discovered while loading configuration file:`);
for (const error of errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
/** Gets the path of the directory for the repository base. */
export function getRepoBaseDir() { export function getRepoBaseDir() {
const baseRepoDir = exec(`git rev-parse --show-toplevel`, {silent: true}); const baseRepoDir = exec(`git rev-parse --show-toplevel`, {silent: true});
if (baseRepoDir.code) { if (baseRepoDir.code) {
@ -26,26 +77,3 @@ export function getRepoBaseDir() {
} }
return baseRepoDir.trim(); return baseRepoDir.trim();
} }
/**
* Retrieve the configuration from the .ng-dev-config.js file.
*/
export function getAngularDevConfig<K, T>(supressError = false): DevInfraConfig<K, T> {
const configPath = join(getRepoBaseDir(), CONFIG_FILE_NAME);
try {
return require(configPath) as DevInfraConfig<K, T>;
} catch (err) {
if (!supressError) {
throw Error(`Unable to load config file at:\n ${configPath}`);
}
}
return {} as DevInfraConfig<K, T>;
}
/**
* Interface exressing the expected structure of the DevInfraConfig.
* Allows for providing a typing for a part of the config to read.
*/
export interface DevInfraConfig<K, T> {
[K: string]: T;
}