Skip to content

Commit

Permalink
feat: simplify config resolution (#2398)
Browse files Browse the repository at this point in the history
* feat: basic user config validation

* fix: simplify config resolution and fix issue #327

* fix: remove no longer needed function

* fix: disable some unwanted validations

* fix: improve config validation

* fix: remove redundant validation

* fix: use reduceRight instead of reverse

* fix: rollback some code

* fix: drop invalid type casts

* fix: rollback unnecessary changes

* fix: rollback config validation

* fix: add missing type-guards and restore order

* fix: one more order change

* fix: add one more missing type guard

* fix: remove unused types reference

* fix: add additional unit tests

* fix: add additional regression tests
- remove also unnecessary type check

* fix: remove more unnecessary code changes

* fix: correct order of merging plugins

* fix: add missing type check

* fix: remove invalid type check

* fix: remove redundant code

* fix: rollback some unnecessary changes

* fix: optimize loadParserOpts
  • Loading branch information
armano2 authored Nov 16, 2021
1 parent c33d493 commit 8a8384f
Show file tree
Hide file tree
Showing 8 changed files with 322 additions and 126 deletions.
2 changes: 1 addition & 1 deletion @commitlint/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,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;
}
Expand Down
44 changes: 26 additions & 18 deletions @commitlint/load/src/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 () => {
Expand Down Expand Up @@ -127,8 +128,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*)(?:\((.*)\))?-(.*)$/,
});
});
Expand Down Expand Up @@ -268,8 +270,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: /.*/,
});
});
Expand All @@ -278,8 +281,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*)(?:\((.*)\))?-(.*)$/,
});
});
Expand Down Expand Up @@ -402,11 +406,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*)(?:\((.*)\))?!?: (.*)$/
);
});
Expand All @@ -415,9 +420,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*)(?:\((.*)\))?: (.*)$/
);
});
Expand All @@ -432,9 +438,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(
/^(:.*?:) (.*)$/
);
});
Expand All @@ -445,11 +452,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*)(?:\((.*)\))?!?: (.*)$/
);
});
Expand Down
100 changes: 41 additions & 59 deletions @commitlint/load/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,21 @@ import executeRule from '@commitlint/execute-rule';
import resolveExtends from '@commitlint/resolve-extends';
import {
LoadOptions,
ParserPreset,
QualifiedConfig,
QualifiedRules,
PluginRecords,
UserConfig,
UserPreset,
} from '@commitlint/types';
import isPlainObject from 'lodash/isPlainObject';
import merge from 'lodash/merge';
import mergeWith from 'lodash/mergeWith';
import pick from 'lodash/pick';
import union from 'lodash/union';
import uniq from 'lodash/uniq';
import Path from 'path';
import resolveFrom from 'resolve-from';
import {loadConfig} from './utils/load-config';
import {loadParserOpts} from './utils/load-parser-opts';
import loadPlugin from './utils/load-plugin';
import {pickConfig} from './utils/pick-config';

const w = <T>(_: unknown, b: ArrayLike<T> | null | undefined | false) =>
Array.isArray(b) ? b : undefined;

export default async function load(
seed: UserConfig = {},
options: LoadOptions = {}
Expand All @@ -35,11 +29,16 @@ 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(
{
extends: [],
plugins: [],
rules: {},
},
loaded ? loaded.config : null,
seed
)
);

// Resolve parserPreset key
Expand All @@ -54,59 +53,35 @@ 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 : [];
}) as unknown as UserConfig;

// 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
);
if (!extended.formatter || typeof extended.formatter !== 'string') {
extended.formatter = '@commitlint/format';
}

// Resolve config-relative formatter module
if (typeof config.formatter === 'string') {
preset.formatter =
resolveFrom.silent(base, config.formatter) || config.formatter;
}

// Read plugins from extends
let plugins: PluginRecords = {};
if (Array.isArray(extended.plugins)) {
config.plugins = union(config.plugins, extended.plugins || []);
}

// resolve plugins
if (Array.isArray(config.plugins)) {
config.plugins.forEach((plugin) => {
uniq(extended.plugins || []).forEach((plugin) => {
if (typeof plugin === 'string') {
loadPlugin(preset.plugins, plugin, process.env.DEBUG === 'true');
plugins = loadPlugin(plugins, plugin, process.env.DEBUG === 'true');
} else {
preset.plugins.local = plugin;
plugins.local = plugin;
}
});
}

const rules = preset.rules ? preset.rules : {};
const qualifiedRules = (
const rules = (
await Promise.all(
Object.entries(rules || {}).map((entry) => executeRule<any>(entry))
Object.entries(extended.rules || {}).map((entry) => executeRule(entry))
)
).reduce<QualifiedRules>((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;
}, {});

Expand All @@ -118,17 +93,24 @@ export default async function load(
: 'https://github.com/conventional-changelog/commitlint/#what-is-commitlint';

const prompt =
preset.prompt && isPlainObject(preset.prompt) ? preset.prompt : {};
extended.prompt && isPlainObject(extended.prompt) ? extended.prompt : {};

return {
extends: preset.extends!,
formatter: preset.formatter!,
parserPreset: preset.parserPreset! as ParserPreset,
ignores: preset.ignores!,
defaultIgnores: preset.defaultIgnores!,
plugins: preset.plugins!,
rules: qualifiedRules,
helpUrl,
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 loadParserOpts(extended.parserPreset),
ignores: extended.ignores,
defaultIgnores: extended.defaultIgnores,
plugins: plugins,
rules: rules,
helpUrl: helpUrl,
prompt,
};
}
77 changes: 50 additions & 27 deletions @commitlint/load/src/utils/load-parser-opts.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,71 @@
import {ParserPreset} from '@commitlint/types';

function isObjectLike(obj: unknown): obj is Record<string, unknown> {
return Boolean(obj) && typeof obj === 'object'; // typeof null === 'object'
}

function isParserOptsFunction<T extends ParserPreset>(
obj: T
): obj is T & {
parserOpts: (...args: any[]) => any;
} {
return typeof obj.parserOpts === 'function';
}

export async function loadParserOpts(
parserName: string,
pendingParser: Promise<any>
) {
pendingParser: string | ParserPreset | Promise<ParserPreset> | undefined
): Promise<ParserPreset | undefined> {
if (!pendingParser || typeof pendingParser !== 'object') {
return undefined;
}
// Await for the module, loaded with require
const parser = await pendingParser;

// Await parser opts if applicable
if (
typeof parser === 'object' &&
typeof parser.parserOpts === 'object' &&
typeof parser.parserOpts.then === 'function'
) {
return (await parser.parserOpts).parserOpts;
// exit early, no opts to resolve
if (!parser.parserOpts) {
return parser;
}

// Pull nested parserOpts, might happen if overwritten with a module in main config
if (typeof parser.parserOpts === 'object') {
// Await parser opts if applicable
parser.parserOpts = await parser.parserOpts;
if (
isObjectLike(parser.parserOpts) &&
isObjectLike(parser.parserOpts.parserOpts)
) {
parser.parserOpts = parser.parserOpts.parserOpts;
}
return parser;
}

// Create parser opts from factory
if (
typeof parser === 'object' &&
typeof parser.parserOpts === 'function' &&
parserName.startsWith('conventional-changelog-')
isParserOptsFunction(parser) &&
typeof parser.name === 'string' &&
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: any) => {
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
if (
typeof parser === 'object' &&
typeof parser.parserOpts === 'object' &&
typeof parser.parserOpts.parserOpts === 'object'
) {
return parser.parserOpts.parserOpts;
}

return parser.parserOpts;
return parser;
}
3 changes: 1 addition & 2 deletions @commitlint/load/src/utils/pick-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {UserConfig} from '@commitlint/types';
import pick from 'lodash/pick';

export const pickConfig = (input: unknown): UserConfig =>
export const pickConfig = (input: unknown): Record<string, unknown> =>
pick(
input,
'extends',
Expand Down
Loading

0 comments on commit 8a8384f

Please sign in to comment.