From 3f6d95c036e660e85c637220c2350fc404f6e4a4 Mon Sep 17 00:00:00 2001 From: Armano Date: Sun, 10 Jan 2021 21:18:53 +0100 Subject: [PATCH] feat: basic user config validation --- @commitlint/cli/src/cli.ts | 2 +- @commitlint/load/src/load.test.ts | 44 +++--- @commitlint/load/src/load.ts | 129 ++++++++---------- .../load/src/utils/load-parser-opts.ts | 59 ++++---- @commitlint/load/src/utils/validators.ts | 59 ++++++++ @commitlint/resolve-extends/package.json | 1 + @commitlint/resolve-extends/src/index.ts | 11 +- @commitlint/resolve-extends/tsconfig.json | 3 +- @commitlint/types/src/load.ts | 10 +- 9 files changed, 192 insertions(+), 126 deletions(-) create mode 100644 @commitlint/load/src/utils/validators.ts diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index b8d4b127a2..0992120221 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -323,7 +323,7 @@ function getSeed(flags: CliFlags): Seed { : {parserPreset: flags['parser-preset']}; } -function selectParserOpts(parserPreset: ParserPreset) { +function selectParserOpts(parserPreset: ParserPreset | undefined) { if (typeof parserPreset !== 'object') { return undefined; } diff --git a/@commitlint/load/src/load.test.ts b/@commitlint/load/src/load.test.ts index 09d2db53d5..ff3530a0c1 100644 --- a/@commitlint/load/src/load.test.ts +++ b/@commitlint/load/src/load.test.ts @@ -22,6 +22,7 @@ test('extends-empty should have no rules', async () => { const actual = await load({}, {cwd}); expect(actual.rules).toMatchObject({}); + expect(actual.parserPreset).not.toBeDefined(); }); test('uses seed as configured', async () => { @@ -128,8 +129,9 @@ test('uses seed with parserPreset', async () => { {cwd} ); - expect(actual.name).toBe('./conventional-changelog-custom'); - expect(actual.parserOpts).toMatchObject({ + expect(actual).toBeDefined(); + expect(actual!.name).toBe('./conventional-changelog-custom'); + expect(actual!.parserOpts).toMatchObject({ headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/, }); }); @@ -253,8 +255,9 @@ test('parser preset overwrites completely instead of merging', async () => { const cwd = await gitBootstrap('fixtures/parser-preset-override'); const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe('./custom'); - expect(actual.parserPreset.parserOpts).toMatchObject({ + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe('./custom'); + expect(actual.parserPreset!.parserOpts).toMatchObject({ headerPattern: /.*/, }); }); @@ -263,8 +266,9 @@ test('recursive extends with parserPreset', async () => { const cwd = await gitBootstrap('fixtures/recursive-parser-preset'); const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe('./conventional-changelog-custom'); - expect(actual.parserPreset.parserOpts).toMatchObject({ + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe('./conventional-changelog-custom'); + expect(actual.parserPreset!.parserOpts).toMatchObject({ headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/, }); }); @@ -387,11 +391,12 @@ test('resolves parser preset from conventional commits', async () => { const cwd = await npmBootstrap('fixtures/parser-preset-conventionalcommits'); const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe( + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe( 'conventional-changelog-conventionalcommits' ); - expect(typeof actual.parserPreset.parserOpts).toBe('object'); - expect((actual.parserPreset.parserOpts as any).headerPattern).toEqual( + expect(typeof actual.parserPreset!.parserOpts).toBe('object'); + expect((actual.parserPreset!.parserOpts as any).headerPattern).toEqual( /^(\w*)(?:\((.*)\))?!?: (.*)$/ ); }); @@ -400,9 +405,10 @@ test('resolves parser preset from conventional angular', async () => { const cwd = await npmBootstrap('fixtures/parser-preset-angular'); const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe('conventional-changelog-angular'); - expect(typeof actual.parserPreset.parserOpts).toBe('object'); - expect((actual.parserPreset.parserOpts as any).headerPattern).toEqual( + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe('conventional-changelog-angular'); + expect(typeof actual.parserPreset!.parserOpts).toBe('object'); + expect((actual.parserPreset!.parserOpts as any).headerPattern).toEqual( /^(\w*)(?:\((.*)\))?: (.*)$/ ); }); @@ -418,9 +424,10 @@ test('recursive resolves parser preset from conventional atom', async () => { const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe('conventional-changelog-atom'); - expect(typeof actual.parserPreset.parserOpts).toBe('object'); - expect((actual.parserPreset.parserOpts as any).headerPattern).toEqual( + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe('conventional-changelog-atom'); + expect(typeof actual.parserPreset!.parserOpts).toBe('object'); + expect((actual.parserPreset!.parserOpts as any).headerPattern).toEqual( /^(:.*?:) (.*)$/ ); }, 10000); @@ -431,11 +438,12 @@ test('resolves parser preset from conventional commits without factory support', ); const actual = await load({}, {cwd}); - expect(actual.parserPreset.name).toBe( + expect(actual.parserPreset).toBeDefined(); + expect(actual.parserPreset!.name).toBe( 'conventional-changelog-conventionalcommits' ); - expect(typeof actual.parserPreset.parserOpts).toBe('object'); - expect((actual.parserPreset.parserOpts as any).headerPattern).toEqual( + expect(typeof actual.parserPreset!.parserOpts).toBe('object'); + expect((actual.parserPreset!.parserOpts as any).headerPattern).toEqual( /^(\w*)(?:\((.*)\))?!?: (.*)$/ ); }); diff --git a/@commitlint/load/src/load.ts b/@commitlint/load/src/load.ts index f6b2e7d002..d03c18d0bd 100644 --- a/@commitlint/load/src/load.ts +++ b/@commitlint/load/src/load.ts @@ -1,8 +1,6 @@ import Path from 'path'; import merge from 'lodash/merge'; -import mergeWith from 'lodash/mergeWith'; -import pick from 'lodash/pick'; import union from 'lodash/union'; import resolveFrom from 'resolve-from'; @@ -12,18 +10,15 @@ import { UserConfig, LoadOptions, QualifiedConfig, - UserPreset, QualifiedRules, - ParserPreset, + PluginRecords, } from '@commitlint/types'; import loadPlugin from './utils/load-plugin'; import {loadConfig} from './utils/load-config'; -import {loadParserOpts} from './utils/load-parser-opts'; +import {loadParser} from './utils/load-parser-opts'; import {pickConfig} from './utils/pick-config'; - -const w = (_: unknown, b: ArrayLike | null | undefined | false) => - Array.isArray(b) ? b : undefined; +import {validateConfig} from './utils/validators'; export default async function load( seed: UserConfig = {}, @@ -37,11 +32,17 @@ export default async function load( // Might amount to breaking changes, defer until 9.0.0 // Merge passed config with file based options - const config = pickConfig(merge({}, loaded ? loaded.config : null, seed)); - - const opts = merge( - {extends: [], rules: {}, formatter: '@commitlint/format'}, - pick(config, 'extends', 'plugins', 'ignores', 'defaultIgnores') + const config = pickConfig( + merge( + { + rules: {}, + formatter: '@commitlint/format', + helpUrl: + 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint', + }, + loaded ? loaded.config : null, + seed + ) ); // Resolve parserPreset key @@ -56,75 +57,63 @@ export default async function load( } // Resolve extends key - const extended = resolveExtends(opts, { + const extended = resolveExtends(config, { prefix: 'commitlint-config', cwd: base, parserPreset: config.parserPreset, }); - const preset = (pickConfig( - mergeWith(extended, config, w) - ) as unknown) as UserPreset; - preset.plugins = {}; - - // TODO: check if this is still necessary with the new factory based conventional changelog parsers - // config.extends = Array.isArray(config.extends) ? config.extends : []; - - // Resolve parser-opts from preset - if (typeof preset.parserPreset === 'object') { - preset.parserPreset.parserOpts = await loadParserOpts( - preset.parserPreset.name, - // TODO: fix the types for factory based conventional changelog parsers - preset.parserPreset as any - ); - } - - // Resolve config-relative formatter module - if (typeof config.formatter === 'string') { - preset.formatter = - resolveFrom.silent(base, config.formatter) || config.formatter; - } - - // Read plugins from extends - if (Array.isArray(extended.plugins)) { - config.plugins = union(config.plugins, extended.plugins || []); - } - - // resolve plugins - if (Array.isArray(config.plugins)) { - config.plugins.forEach((plugin) => { - if (typeof plugin === 'string') { - loadPlugin(preset.plugins, plugin, process.env.DEBUG === 'true'); - } else { - preset.plugins.local = plugin; - } - }); - } + validateConfig(extended); + + let plugins: PluginRecords = {}; + // TODO: this object merging should be done in resolveExtends + union( + // Read plugins from config + Array.isArray(config.plugins) ? config.plugins : [], + // Read plugins from extends + Array.isArray(extended.plugins) ? extended.plugins : [] + ).forEach((plugin) => { + if (typeof plugin === 'string') { + plugins = loadPlugin(plugins, plugin, process.env.DEBUG === 'true'); + } else { + plugins.local = plugin; + } + }); - const rules = preset.rules ? preset.rules : {}; - const qualifiedRules = ( + const rules = ( await Promise.all( - Object.entries(rules || {}).map((entry) => executeRule(entry)) + Object.entries({ + ...(typeof extended.rules === 'object' ? extended.rules || {} : {}), + ...(typeof config.rules === 'object' ? config.rules || {} : {}), + }).map((entry) => executeRule(entry)) ) ).reduce((registry, item) => { - const [key, value] = item as any; - (registry as any)[key] = value; + // type of `item` can be null, but Object.entries always returns key pair + const [key, value] = item!; + registry[key] = value; return registry; }, {}); - const helpUrl = - typeof config.helpUrl === 'string' - ? config.helpUrl - : 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint'; - return { - extends: preset.extends!, - formatter: preset.formatter!, - parserPreset: preset.parserPreset! as ParserPreset, - ignores: preset.ignores!, - defaultIgnores: preset.defaultIgnores!, - plugins: preset.plugins!, - rules: qualifiedRules, - helpUrl, + // TODO: check if this is still necessary with the new factory based conventional changelog parsers + // TODO: should this function return this? as those values are already resolved + extends: Array.isArray(extended.extends) + ? extended.extends + : typeof extended.extends === 'string' + ? [extended.extends] + : [], + // Resolve config-relative formatter module + formatter: + resolveFrom.silent(base, extended.formatter) || extended.formatter, + // Resolve parser-opts from preset + parserPreset: await loadParser(extended.parserPreset), + ignores: extended.ignores, + defaultIgnores: extended.defaultIgnores, + plugins: plugins, + rules: rules, + helpUrl: + typeof extended.helpUrl === 'string' + ? extended.helpUrl + : 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint', }; } diff --git a/@commitlint/load/src/utils/load-parser-opts.ts b/@commitlint/load/src/utils/load-parser-opts.ts index 86d5b5b700..5380f529ba 100644 --- a/@commitlint/load/src/utils/load-parser-opts.ts +++ b/@commitlint/load/src/utils/load-parser-opts.ts @@ -1,48 +1,61 @@ -export async function loadParserOpts( - parserName: string, - pendingParser: Promise -) { +import {ParserPreset} from '@commitlint/types'; +import { + isObjectLike, + isParserOptsFunction, + isPromiseLike, + validateParser, +} from './validators'; + +export async function loadParser( + pendingParser: unknown +): Promise { + if (!pendingParser) { + return undefined; + } // Await for the module, loaded with require const parser = await pendingParser; + validateParser(parser); + // Await parser opts if applicable - if ( - typeof parser === 'object' && - typeof parser.parserOpts === 'object' && - typeof parser.parserOpts.then === 'function' - ) { - return (await parser.parserOpts).parserOpts; + if (isPromiseLike(parser.parserOpts)) { + parser.parserOpts = ((await parser.parserOpts) as any).parserOpts; + return parser; } // Create parser opts from factory if ( - typeof parser === 'object' && - typeof parser.parserOpts === 'function' && - parserName.startsWith('conventional-changelog-') + isParserOptsFunction(parser) && + parser.name.startsWith('conventional-changelog-') ) { - return await new Promise((resolve) => { - const result = parser.parserOpts((_: never, opts: {parserOpts: any}) => { - resolve(opts.parserOpts); + return new Promise((resolve) => { + const result = parser.parserOpts((_: never, opts) => { + resolve({ + ...parser, + parserOpts: opts.parserOpts, + }); }); // If result has data or a promise, the parser doesn't support factory-init // due to https://github.com/nodejs/promises-debugging/issues/16 it just quits, so let's use this fallback if (result) { Promise.resolve(result).then((opts) => { - resolve(opts.parserOpts); + resolve({ + ...parser, + parserOpts: opts.parserOpts, + }); }); } + return; }); } - // Pull nested paserOpts, might happen if overwritten with a module in main config + // Pull nested parserOpts, might happen if overwritten with a module in main config if ( - typeof parser === 'object' && - typeof parser.parserOpts === 'object' && + isObjectLike(parser.parserOpts) && typeof parser.parserOpts.parserOpts === 'object' ) { - return parser.parserOpts.parserOpts; + parser.parserOpts = parser.parserOpts.parserOpts; } - - return parser.parserOpts; + return parser; } diff --git a/@commitlint/load/src/utils/validators.ts b/@commitlint/load/src/utils/validators.ts new file mode 100644 index 0000000000..f26da6aaf5 --- /dev/null +++ b/@commitlint/load/src/utils/validators.ts @@ -0,0 +1,59 @@ +export function isObjectLike(obj: unknown): obj is Record { + return Boolean(obj) && typeof obj === 'object'; // typeof null === 'object' +} + +export function isPromiseLike(obj: unknown): obj is Promise { + return ( + (typeof obj === 'object' || typeof obj === 'function') && + typeof (obj as any).then === 'function' + ); +} + +export function isParserOptsFunction>( + obj: T +): obj is T & { + parserOpts: ( + cb: (_: never, parserOpts: Record) => unknown + ) => Record | undefined; +} { + return typeof obj.parserOpts === 'function'; +} + +export function validateConfig( + config: Record +): asserts config is { + formatter: string; + ignores?: ((commit: string) => boolean)[]; + defaultIgnores?: boolean; + [key: string]: unknown; +} { + if (!isObjectLike(config)) { + throw new Error('Invalid configuration, parserPreset must be an object'); + } + if (typeof config.formatter !== 'string') { + throw new Error('Invalid configuration, formatter must be a string'); + } + if (config.ignores && !Array.isArray(config.ignores)) { + throw new Error('Invalid configuration, ignores must ba an array'); + } + if ( + typeof config.defaultIgnores !== 'boolean' && + typeof config.defaultIgnores !== 'undefined' + ) { + throw new Error('Invalid configuration, defaultIgnores must ba true/false'); + } +} + +export function validateParser( + parser: unknown +): asserts parser is {name: string; path: string; [key: string]: unknown} { + if (!isObjectLike(parser)) { + throw new Error('Invalid configuration, parserPreset must be an object'); + } + if (typeof parser.name !== 'string') { + throw new Error('Invalid configuration, parserPreset must have a name'); + } + if (typeof parser.path !== 'string') { + throw new Error('Invalid configuration, parserPreset must have a name'); + } +} diff --git a/@commitlint/resolve-extends/package.json b/@commitlint/resolve-extends/package.json index 8d69557250..9cfb50fa09 100644 --- a/@commitlint/resolve-extends/package.json +++ b/@commitlint/resolve-extends/package.json @@ -38,6 +38,7 @@ "@types/lodash": "^4.14.161" }, "dependencies": { + "@commitlint/types": "^11.0.0", "import-fresh": "^3.0.0", "lodash": "^4.17.19", "resolve-from": "^5.0.0", diff --git a/@commitlint/resolve-extends/src/index.ts b/@commitlint/resolve-extends/src/index.ts index f8c786c18b..351c3d7dd3 100644 --- a/@commitlint/resolve-extends/src/index.ts +++ b/@commitlint/resolve-extends/src/index.ts @@ -4,6 +4,7 @@ import 'resolve-global'; import resolveFrom from 'resolve-from'; import merge from 'lodash/merge'; import mergeWith from 'lodash/mergeWith'; +import {UserConfig} from '@commitlint/types'; const importFresh = require('import-fresh'); @@ -12,12 +13,6 @@ export interface ResolvedConfig { [key: string]: unknown; } -export interface ResolveExtendsConfig { - parserPreset?: unknown; - extends?: string | string[]; - [key: string]: unknown; -} - export interface ResolveExtendsContext { cwd?: string; parserPreset?: unknown; @@ -28,7 +23,7 @@ export interface ResolveExtendsContext { } export default function resolveExtends( - config: ResolveExtendsConfig = {}, + config: UserConfig = {}, context: ResolveExtendsContext = {} ) { const {extends: e} = config; @@ -46,7 +41,7 @@ export default function resolveExtends( } function loadExtends( - config: ResolveExtendsConfig = {}, + config: UserConfig = {}, context: ResolveExtendsContext = {} ): ResolvedConfig[] { const {extends: e} = config; diff --git a/@commitlint/resolve-extends/tsconfig.json b/@commitlint/resolve-extends/tsconfig.json index 49479bf34f..119e645565 100644 --- a/@commitlint/resolve-extends/tsconfig.json +++ b/@commitlint/resolve-extends/tsconfig.json @@ -6,5 +6,6 @@ "outDir": "./lib" }, "include": ["./src"], - "exclude": ["./src/**/*.test.ts", "./lib/**/*"] + "exclude": ["./src/**/*.test.ts", "./lib/**/*"], + "references": [{"path": "../types"}] } diff --git a/@commitlint/types/src/load.ts b/@commitlint/types/src/load.ts index 481b477e0f..2dfd8fd389 100644 --- a/@commitlint/types/src/load.ts +++ b/@commitlint/types/src/load.ts @@ -14,10 +14,10 @@ export interface LoadOptions { } export interface UserConfig { - extends?: string[]; + extends?: string | string[]; formatter?: string; rules?: Partial; - parserPreset?: string | ParserPreset; + parserPreset?: string | ParserPreset | Promise; ignores?: ((commit: string) => boolean)[]; defaultIgnores?: boolean; plugins?: (string | Plugin)[]; @@ -40,9 +40,9 @@ export interface QualifiedConfig { extends: string[]; formatter: string; rules: QualifiedRules; - parserPreset: ParserPreset; - ignores: ((commit: string) => boolean)[]; - defaultIgnores: boolean; + parserPreset?: ParserPreset; + ignores?: ((commit: string) => boolean)[]; + defaultIgnores?: boolean; plugins: PluginRecords; helpUrl: string; }