Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
95b592c
feat(cli): add --show-position flag to display error location
escapedcat Feb 27, 2026
684d015
test(lint): add tests for getRulePosition function
escapedcat Feb 27, 2026
083f8fc
fix: remove duplicate test names
escapedcat Feb 27, 2026
43ad330
fix: improve position indicator for empty field rules and fallback ha…
escapedcat Feb 27, 2026
f87bca6
fix: resolve remaining PR comments
escapedcat Feb 27, 2026
24a9b02
refactor: export shared Position type from @commitlint/types
escapedcat Feb 27, 2026
28849c6
refactor: generalize line handling and fix edge cases
escapedcat Feb 27, 2026
a8f3b91
feat!: enable position indicator by default
escapedcat Mar 1, 2026
1dbc322
fix(format): strip ANSI codes when computing position indicator offset
escapedcat May 2, 2026
e99b097
fix(lint): compute body start line from actual offset
escapedcat May 2, 2026
cfe7fdd
fix(lint): compute footer start line from actual offset
escapedcat May 2, 2026
a2cdb1f
fix(lint): provide position for body-leading-blank when blank is missing
escapedcat May 2, 2026
a4d4461
fix(lint): provide position for footer-leading-blank when blank is mi…
escapedcat May 2, 2026
a402e38
fix(lint): respect custom parser headerPattern for subject position
escapedcat May 2, 2026
c08992a
fix(format): render position indicator under the failing line for mul…
escapedcat May 2, 2026
ab55526
test(cli): match each input line individually for stdin-failure output
escapedcat May 3, 2026
cfe9a3b
test: cover position indicator fixes
escapedcat May 3, 2026
e0bad19
fix(lint): use lastIndexOf for subject position
escapedcat May 4, 2026
87bfde8
fix(lint): locate type in header instead of assuming raw starts with it
escapedcat May 4, 2026
7d95a7e
fix(lint): normalize CRLF before computing rule positions
escapedcat May 4, 2026
94ca031
fix(lint): point subject-exclamation-mark caret at the bang position
escapedcat May 4, 2026
89612ee
docs(format): document start/end problem fields for position indicator
escapedcat May 4, 2026
32ff054
test(cli): add integration coverage for --show-position default and o…
escapedcat May 4, 2026
dd59f49
fix(lint): pin subject-exclamation-mark caret to the bang adjacent to…
escapedcat May 4, 2026
fde3db1
fix(lint): point leading-blank carets at the actual section boundary
escapedcat May 4, 2026
0884f25
fix(lint): provide body-empty position for header-only commits
escapedcat May 4, 2026
1153f82
fix(lint): clamp body/footer end offset to raw.length
escapedcat May 4, 2026
54fc1d0
refactor(types): adopt Position in LintRuleOutcome and document units
escapedcat May 4, 2026
01945ae
docs(cli): add --show-position to the CLI reference
escapedcat May 4, 2026
3a9ec87
test(lint): add subject position test for custom parserOpts headerPat…
escapedcat May 4, 2026
79451a7
test(cli): assert full-message reprint preserves input line order
escapedcat May 4, 2026
834e8b3
refactor(lint): simplify getRulePosition with field-level helpers
escapedcat May 4, 2026
a1e3f5c
feat(cli): make --show-position opt-in (default off)
escapedcat May 4, 2026
037a6a9
test(config-conventional): assert positions explicitly
escapedcat May 4, 2026
5aed3d3
feat!: enable position indicator by default
escapedcat May 4, 2026
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
5 changes: 4 additions & 1 deletion @commitlint/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,9 @@ test("should print full commit message when input from stdin fails", async () =>
// output text in plain text so we can compare it
const result = cli(["--color=false"], { cwd })(input);
const output = await result;
expect(output.stdout.trim()).toContain(input);
for (const line of input.split("\n").filter((l) => l.length > 0)) {
expect(output.stdout).toContain(line);
}
expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault);
});

Expand Down Expand Up @@ -644,6 +646,7 @@ test("should print help", async () => {
-q, --quiet toggle console output [boolean] [default: false]
-t, --to upper end of the commit range to lint; applies if edit=false [string]
-V, --verbose enable verbose output for reports without problems [boolean]
--show-position show position of error in output [boolean] [default: true]
-s, --strict enable strict mode; result code 2 for warnings, 3 for errors [boolean]
--options path to a JSON file or Common.js module containing CLI options
-v, --version display version information [boolean]
Expand Down
6 changes: 6 additions & 0 deletions @commitlint/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ const cli = yargs(process.argv.slice(2))
type: "boolean",
description: "enable verbose output for reports without problems",
},
"show-position": {
type: "boolean",
default: true,
Comment thread
escapedcat marked this conversation as resolved.
description: "show position of error in output",
Comment thread
escapedcat marked this conversation as resolved.
},
strict: {
alias: "s",
type: "boolean",
Expand Down Expand Up @@ -398,6 +403,7 @@ async function main(args: MainArgs): Promise<void> {
color: flags.color,
verbose: flags.verbose,
helpUrl,
showPosition: flags["show-position"],
});

if (!flags.quiet && output !== "") {
Expand Down
1 change: 1 addition & 0 deletions @commitlint/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CliFlags {
to?: string;
version?: boolean;
verbose?: boolean;
"show-position"?: boolean;
/** @type {'' | 'text' | 'json'} */
"print-config"?: string;
strict?: boolean;
Expand Down
22 changes: 11 additions & 11 deletions @commitlint/config-conventional/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,21 @@ test("type-enum", async () => {
const result = await commitLint(messages.invalidTypeEnum);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.typeEnum]);
expect(result.errors).toMatchObject([errors.typeEnum]);
});

test("type-case", async () => {
const result = await commitLint(messages.invalidTypeCase);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.typeCase, errors.typeEnum]);
expect(result.errors).toMatchObject([errors.typeCase, errors.typeEnum]);
});

test("type-empty", async () => {
const result = await commitLint(messages.invalidTypeEmpty);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.typeEmpty]);
expect(result.errors).toMatchObject([errors.typeEmpty]);
});

test("subject-case", async () => {
Expand All @@ -158,57 +158,57 @@ test("subject-case", async () => {

invalidInputs.forEach((result) => {
expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectCase]);
expect(result.errors).toMatchObject([errors.subjectCase]);
});
});

test("subject-empty", async () => {
const result = await commitLint(messages.invalidSubjectEmpty);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectEmpty, errors.typeEmpty]);
expect(result.errors).toMatchObject([errors.subjectEmpty, errors.typeEmpty]);
});

test("subject-full-stop", async () => {
const result = await commitLint(messages.invalidSubjectFullStop);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.subjectFullStop]);
expect(result.errors).toMatchObject([errors.subjectFullStop]);
});

test("header-max-length", async () => {
const result = await commitLint(messages.invalidHeaderMaxLength);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.headerMaxLength]);
expect(result.errors).toMatchObject([errors.headerMaxLength]);
});

test("footer-leading-blank", async () => {
const result = await commitLint(messages.warningFooterLeadingBlank);

expect(result.valid).toBe(true);
expect(result.warnings).toEqual([warnings.footerLeadingBlank]);
expect(result.warnings).toMatchObject([warnings.footerLeadingBlank]);
});

test("footer-max-line-length", async () => {
const result = await commitLint(messages.invalidFooterMaxLineLength);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.footerMaxLineLength]);
expect(result.errors).toMatchObject([errors.footerMaxLineLength]);
});

test("body-leading-blank", async () => {
const result = await commitLint(messages.warningBodyLeadingBlank);

expect(result.valid).toBe(true);
expect(result.warnings).toEqual([warnings.bodyLeadingBlank]);
expect(result.warnings).toMatchObject([warnings.bodyLeadingBlank]);
});

test("body-max-line-length", async () => {
const result = await commitLint(messages.invalidBodyMaxLineLength);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([errors.bodyMaxLineLength]);
expect(result.errors).toMatchObject([errors.bodyMaxLineLength]);
});

test("valid messages", async () => {
Expand Down
221 changes: 220 additions & 1 deletion @commitlint/format/src/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test("returns empty summary with full commit message if verbose", () => {
);

expect(actual).toStrictEqual(
"⧗ input: feat(cli): this is a valid header\n\nThis is a valid body\n\nSigned-off-by: tester\n✔ found 0 problems, 0 warnings",
"⧗ input: feat(cli): this is a valid header\n \n This is a valid body\n \n Signed-off-by: tester\n✔ found 0 problems, 0 warnings",
);
});

Expand Down Expand Up @@ -303,3 +303,222 @@ test("format result should not contain `Get help` prefix if helpUrl is not provi
expect.arrayContaining([expect.stringContaining("Get help:")]),
);
});

test("shows position indicator when showPosition is true and error has position", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "type-enum",
message: "type must be one of [feat, fix]",
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 4, offset: 3 },
},
],
input: "foo: some message",
},
],
},
{
showPosition: true,
color: false,
},
);

expect(actual).toContain("^");
});

test("does not show position indicator when showPosition is false", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "type-enum",
message: "type must be one of [feat, fix]",
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 4, offset: 3 },
},
],
input: "foo: some message",
},
],
},
{
showPosition: false,
color: false,
},
);

expect(actual).not.toContain("^");
});

test("shows position indicator when showPosition is not provided (default)", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "type-enum",
message: "type must be one of [feat, fix]",
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 4, offset: 3 },
},
],
input: "foo: some message",
},
],
},
{
color: false,
},
);

expect(actual).toContain("^");
});

test("does not show position indicator when error has no position", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "type-enum",
message: "type must be one of [feat, fix]",
},
],
input: "foo: some message",
},
],
},
{
showPosition: true,
color: false,
},
);

expect(actual).not.toContain("^");
});

test("shows correct position for subject error", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "subject-max-length",
message: "subject must not be longer than 72 characters",
start: { line: 1, column: 10, offset: 9 },
end: { line: 1, column: 50, offset: 49 },
},
],
input:
"feat: this is a subject that is way too long for the commit message format",
},
],
},
{
showPosition: true,
color: false,
},
);

expect(actual).toContain("^");
});

test("position indicator alignment is identical with or without color", () => {
const result = {
errors: [
{
level: 2 as const,
name: "subject-max-length",
message: "subject must not be longer than 72 characters",
start: { line: 1, column: 10, offset: 9 },
end: { line: 1, column: 50, offset: 49 },
},
],
input: "feat: this subject is going to be a bit too long",
};

const colored = format({ results: [result] }, { showPosition: true });
const plain = format(
{ results: [result] },
{ showPosition: true, color: false },
);

const indicatorOf = (output: string) =>
output.split("\n").find((line) => line.trimStart().startsWith("^"));

expect(indicatorOf(colored)).toBe(indicatorOf(plain));
});

test("renders position indicator under the failing line for multi-line input", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "body-max-line-length",
message: "body must not have lines longer than 80 characters",
start: { line: 3, column: 1, offset: 14 },
end: { line: 3, column: 100, offset: 113 },
},
],
input: "feat: header\n\nthis body line is far too long to fit",
},
],
},
{
showPosition: true,
color: false,
},
);

const lines = actual.split("\n");
const bodyLineIndex = lines.findIndex((l) =>
l.includes("this body line is far too long"),
);
expect(bodyLineIndex).toBeGreaterThan(-1);
expect(lines[bodyLineIndex + 1]).toContain("^");
});

test("shows position indicator with single caret for longer errors", () => {
const actual = format(
{
results: [
{
errors: [
{
level: 2,
name: "header-max-length",
message: "header must not be longer than 100 characters",
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 80, offset: 79 },
},
],
input:
"feat: this is a very long header that exceeds the maximum allowed character limit for the commit message",
},
],
},
{
showPosition: true,
color: false,
},
);

expect(actual).toContain("^");
Comment thread
escapedcat marked this conversation as resolved.
});
Loading