Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
extends: ['./extended'],
parserPreset: {
parserOpts: {
issuePrefixes: ['PROJ-'],
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
parserOpts: {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
headerCorrespondence: ['type', 'scope', 'subject'],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
parserPreset: './conventional-changelog-custom',
};
15 changes: 15 additions & 0 deletions @commitlint/load/src/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,21 @@ test("parser preset overwrites completely instead of merging", async () => {
});
});

// https://github.com/conventional-changelog/commitlint/issues/4640
test("partial user parserPreset merges with extended string parserPreset", async () => {
const cwd = await gitBootstrap(
"fixtures/parser-preset-partial-user-override",
);
const actual = await load({}, { cwd });

expect(actual.parserPreset).toBeDefined();
expect(actual.parserPreset!.parserOpts).toMatchObject({
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
headerCorrespondence: ["type", "scope", "subject"],
issuePrefixes: ["PROJ-"],
});
});

test("recursive extends with parserPreset", async () => {
const cwd = await gitBootstrap("fixtures/recursive-parser-preset");
const actual = await load({}, { cwd });
Expand Down
9 changes: 8 additions & 1 deletion @commitlint/load/src/utils/load-parser-opts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,14 @@ export async function loadParserOpts(
isObjectLike(parser.parserOpts) &&
isObjectLike(parser.parserOpts.parserOpts)
) {
parser.parserOpts = parser.parserOpts.parserOpts;
// Preserve any user-provided properties (e.g. issuePrefixes) that
// were merged at the outer parserOpts level during config resolution,
// while unwrapping the inner module-provided parserOpts (#4640).
const { parserOpts: inner, ...rest } = parser.parserOpts as Record<
string,
unknown
> & { parserOpts: Record<string, unknown> };
parser.parserOpts = { ...inner, ...rest };
}
return parser;
}
Expand Down
49 changes: 49 additions & 0 deletions @commitlint/resolve-extends/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,55 @@ test("parserPreset should be merged correctly", async () => {
expect(actual).toEqual(expected);
});

// https://github.com/conventional-changelog/commitlint/issues/4640
// Verifies that mergeWith deep-merges parserPreset objects so that a
// user's partial override (issuePrefixes) coexists with the extended
// config's properties (headerPattern, headerCorrespondence).
// The full string-to-object resolution path is covered by the
// integration test in @commitlint/load ("partial user parserPreset
// merges with extended string parserPreset").
test("user partial parserPreset should merge with extended parserPreset", async () => {
const input = {
extends: ["extender-name"],
parserPreset: {
parserOpts: {
issuePrefixes: ["PROJ-"],
},
},
};

const dynamicImport = (id: string) => {
switch (id) {
case "extender-name":
return {
parserPreset: {
parserOpts: {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
headerCorrespondence: ["type", "scope", "subject"],
},
Comment thread
omar-y-abdi marked this conversation as resolved.
},
};
default:
return {};
}
};

const ctx = {
resolve: id,
dynamicImport: vi.fn(dynamicImport),
} as ResolveExtendsContext;

const actual = await resolveExtends(input, ctx);

expect(actual.parserPreset).toEqual({
parserOpts: {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
headerCorrespondence: ["type", "scope", "subject"],
issuePrefixes: ["PROJ-"],
},
});
});

test("should correctly merge nested configs", async () => {
const input = { extends: ["extender-1"] };

Expand Down
23 changes: 13 additions & 10 deletions @commitlint/resolve-extends/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,21 @@ async function loadExtends(
return await ext.reduce(async (configs, raw) => {
const resolved = resolveConfig(raw, context);

const c = await (context.dynamicImport || dynamicImport)<{
parserPreset?: string;
}>(resolved);
// Shallow-copy so we never mutate an ESM namespace object (#4647).
const c = {
...(await (context.dynamicImport || dynamicImport)<{
parserPreset?: string | ParserPreset;
}>(resolved)),
};
const cwd = path.dirname(resolved);
const ctx = { ...context, cwd };

// Resolve parser preset if none was present before
if (
!context.parserPreset &&
typeof c === "object" &&
typeof c.parserPreset === "string"
) {
// Always resolve string parser presets from extended configs so that
// their parserOpts (headerPattern, etc.) are available for merging.
// Previously this was skipped when the user provided any parserPreset,
// which caused partial user overrides (e.g. just issuePrefixes) to
// lose the extended preset's headerPattern (see #4640).
if (typeof c === "object" && typeof c.parserPreset === "string") {
const resolvedParserPreset = resolveFrom(c.parserPreset, cwd);

const parserPreset: ParserPreset = {
Comment thread
omar-y-abdi marked this conversation as resolved.
Expand All @@ -159,7 +162,7 @@ async function loadExtends(
};

ctx.parserPreset = parserPreset;
config.parserPreset = parserPreset;
c.parserPreset = parserPreset;
}

validateConfig(resolved, config);
Expand Down