From 95b592c1731bf24b674702cbaaddc95e0779ad40 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 15:04:07 +0100 Subject: [PATCH 01/35] feat(cli): add --show-position flag to display error location Adds a new --show-position CLI option that displays a position indicator (~~~) under the commit input to show exactly where the error occurs, similar to TypeScript's red squiggly lines. This helps users quickly identify the problematic part of their commit message. Features: - Add optional start/end position fields to LintRuleOutcome and FormattableProblem - Add getRulePosition() helper to calculate error positions for various rules - Add showPosition option to FormatOptions - Add --show-position CLI flag (opt-in, default false) - Add tests for position indicator in format package - Update config-conventional tests to use toMatchObject for backward compatibility --- @commitlint/cli/src/cli.test.ts | 1 + @commitlint/cli/src/cli.ts | 5 + @commitlint/cli/src/types.ts | 1 + .../config-conventional/src/index.test.ts | 22 +-- @commitlint/format/src/format.test.ts | 163 ++++++++++++++++++ @commitlint/format/src/format.ts | 76 +++++++- @commitlint/lint/src/lint.ts | 144 +++++++++++++++- @commitlint/types/src/format.ts | 3 + @commitlint/types/src/lint.ts | 4 + docs/api/format.md | 5 + 10 files changed, 406 insertions(+), 18 deletions(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 7182a12761..94267b013d 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -644,6 +644,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] -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] diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index dabedae66f..b188ac9de0 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -135,6 +135,10 @@ const cli = yargs(process.argv.slice(2)) type: "boolean", description: "enable verbose output for reports without problems", }, + "show-position": { + type: "boolean", + description: "show position of error in output", + }, strict: { alias: "s", type: "boolean", @@ -398,6 +402,7 @@ async function main(args: MainArgs): Promise { color: flags.color, verbose: flags.verbose, helpUrl, + showPosition: flags["show-position"], }); if (!flags.quiet && output !== "") { diff --git a/@commitlint/cli/src/types.ts b/@commitlint/cli/src/types.ts index cbc9a8956a..032645fdf9 100644 --- a/@commitlint/cli/src/types.ts +++ b/@commitlint/cli/src/types.ts @@ -17,6 +17,7 @@ export interface CliFlags { to?: string; version?: boolean; verbose?: boolean; + "show-position"?: boolean; /** @type {'' | 'text' | 'json'} */ "print-config"?: string; strict?: boolean; diff --git a/@commitlint/config-conventional/src/index.test.ts b/@commitlint/config-conventional/src/index.test.ts index cd847ff2d3..472eef0f24 100644 --- a/@commitlint/config-conventional/src/index.test.ts +++ b/@commitlint/config-conventional/src/index.test.ts @@ -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 () => { @@ -158,7 +158,7 @@ test("subject-case", async () => { invalidInputs.forEach((result) => { expect(result.valid).toBe(false); - expect(result.errors).toEqual([errors.subjectCase]); + expect(result.errors).toMatchObject([errors.subjectCase]); }); }); @@ -166,49 +166,49 @@ 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 () => { diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 3388e96afb..161ee111fd 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -303,3 +303,166 @@ 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("does not show position indicator when showPosition is not provided", () => { + 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).not.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("shows position indicator with multiple tildes 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( + "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~", + ); +}); diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 2496d7272e..a399035c89 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -5,6 +5,7 @@ import { FormatOptions, FormattableResult, WithInput, + FormattableProblem, } from "@commitlint/types"; const DEFAULT_SIGNS = [" ", "⚠", "✖"] as const; @@ -37,7 +38,7 @@ function formatInput( result: FormattableResult & WithInput, options: FormatOptions = {}, ): string[] { - const { color: enabled = true } = options; + const { color: enabled = true, showPosition = false } = options; const { errors = [], warnings = [], input = "" } = result; if (!input) { @@ -50,9 +51,76 @@ function formatInput( const decoratedInput = enabled ? pc.bold(input) : input; const hasProblems = errors.length > 0 || warnings.length > 0; - return options.verbose || hasProblems - ? [`${decoration} input: ${decoratedInput}`] - : []; + if (!hasProblems) { + return options.verbose ? [`${decoration} input: ${decoratedInput}`] : []; + } + + const positionIndicator = showPosition + ? getPositionIndicator(errors, input) + : undefined; + + const lines: string[] = [`${decoration} input: ${decoratedInput}`]; + + if (positionIndicator) { + lines.push(positionIndicator); + } + + return lines; +} + +function getPositionIndicator( + problems: FormattableProblem[], + input: string, +): string | undefined { + const firstError = problems[0]; + if (!firstError?.start || !firstError?.end) { + return undefined; + } + + const { start, end } = firstError; + const padding = " "; + + const tilde = "~"; + let indicator = ""; + + if (start.line === 1) { + const spacesBefore = Math.max(0, start.column - 1); + const tildeLength = Math.max(1, end.column - start.column); + indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); + } else if (start.line === 2) { + const headerEndIndex = input.indexOf("\n\n"); + if (headerEndIndex === -1) return undefined; + + const bodyLineStart = headerEndIndex + 2; + const charsOnLine = input.slice(bodyLineStart).indexOf("\n"); + const lineLength = + charsOnLine === -1 ? input.length - bodyLineStart : charsOnLine; + + if (start.column <= lineLength) { + const spacesBefore = Math.max(0, start.column - 1); + const tildeLength = Math.max( + 1, + Math.min(end.column, lineLength) - start.column, + ); + indicator = + padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); + } + } else if (start.line === 3) { + const footerStartIndex = input.lastIndexOf("\n\n"); + if (footerStartIndex === -1) return undefined; + + const footerLineStart = footerStartIndex + 2; + const lineLength = input.length - footerLineStart; + + if (start.column <= lineLength) { + const spacesBefore = Math.max(0, start.column - 1); + const tildeLength = Math.max(1, end.column - start.column); + indicator = + padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); + } + } + + return indicator || undefined; } export function formatResult( diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index c64bb829e0..4246812630 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -15,6 +15,137 @@ import { RuleConfigSeverity } from "@commitlint/types"; import { buildCommitMessage } from "./commit-message.js"; +interface Position { + line: number; + column: number; + offset: number; +} + +function getRulePosition( + ruleName: string, + parsed: { + raw?: string; + header?: string | null; + type?: string | null; + subject?: string | null; + scope?: string | null; + body?: string | null; + footer?: string | null; + }, +): { start: Position; end: Position } | undefined { + const raw = parsed.raw || ""; + if (!raw) return undefined; + + const header = parsed.header || ""; + + switch (ruleName) { + case "type-enum": + case "type-empty": + case "type-case": + case "type-min-length": + case "type-max-length": { + if (!parsed.type) return undefined; + const offset = raw.indexOf(parsed.type); + if (offset === -1) return undefined; + return { + start: { line: 1, column: offset + 1, offset }, + end: { + line: 1, + column: offset + parsed.type.length + 1, + offset: offset + parsed.type.length, + }, + }; + } + case "scope-enum": + case "scope-empty": + case "scope-case": + case "scope-min-length": + case "scope-max-length": + case "scope-delimiter-style": { + if (!parsed.scope) return undefined; + const scopeStart = raw.indexOf(`(${parsed.scope})`); + if (scopeStart === -1) return undefined; + return { + start: { line: 1, column: scopeStart + 2, offset: scopeStart + 1 }, + end: { + line: 1, + column: scopeStart + parsed.scope.length + 2, + offset: scopeStart + parsed.scope.length + 1, + }, + }; + } + case "subject-empty": + case "subject-case": + case "subject-min-length": + case "subject-max-length": + case "subject-full-stop": + case "subject-exclamation-mark": { + if (!parsed.subject) return undefined; + const subjectStart = raw.indexOf(parsed.subject); + if (subjectStart === -1) return undefined; + return { + start: { line: 1, column: subjectStart + 1, offset: subjectStart }, + end: { + line: 1, + column: subjectStart + parsed.subject.length + 1, + offset: subjectStart + parsed.subject.length, + }, + }; + } + case "header-min-length": + case "header-max-length": + case "header-case": + case "header-full-stop": + case "header-trim": { + if (!header) return undefined; + return { + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: header.length + 1, offset: header.length }, + }; + } + case "body-empty": + case "body-min-length": + case "body-max-length": + case "body-case": + case "body-full-stop": + case "body-leading-blank": + case "body-max-line-length": { + if (!parsed.body) return undefined; + const bodyOffset = raw.indexOf("\n\n"); + if (bodyOffset === -1) return undefined; + const bodyStartOffset = bodyOffset + 2; + return { + start: { line: 2, column: 1, offset: bodyStartOffset }, + end: { + line: 2, + column: parsed.body.length + 1, + offset: bodyStartOffset + parsed.body.length, + }, + }; + } + case "footer-empty": + case "footer-min-length": + case "footer-max-length": + case "footer-leading-blank": + case "footer-max-line-length": { + if (!parsed.footer) return undefined; + const footerOffset = raw.lastIndexOf("\n\n"); + if (footerOffset === -1) return undefined; + const footerStartOffset = footerOffset + 2; + return { + start: { line: 3, column: 1, offset: footerStartOffset }, + end: { + line: 3, + column: parsed.footer.length + 1, + offset: footerStartOffset + parsed.footer.length, + }, + }; + } + default: + return undefined; + } +} + export default async function lint( message: string, rawRulesConfig?: QualifiedRules, @@ -169,16 +300,23 @@ export default async function lint( const executableRule = rule as Rule; const [valid, message] = await executableRule(parsed, when, value); - return { + const position = !valid ? getRulePosition(name, parsed) : undefined; + + const outcome: LintRuleOutcome = { level, valid, name, - message, + message: message ?? "", + start: position?.start, + end: position?.end, }; + + return outcome; }); const results = (await Promise.all(pendingResults)).filter( - (result): result is LintRuleOutcome => result !== null, + (result): result is LintRuleOutcome => + result !== null && result.message !== undefined, ); const errors = results.filter( diff --git a/@commitlint/types/src/format.ts b/@commitlint/types/src/format.ts index ca9f87495a..09960e9e9c 100644 --- a/@commitlint/types/src/format.ts +++ b/@commitlint/types/src/format.ts @@ -11,6 +11,8 @@ export interface FormattableProblem { level: RuleConfigSeverity; name: keyof QualifiedRules; message: string; + start?: { line: number; column: number; offset: number }; + end?: { line: number; column: number; offset: number }; } export interface FormattableResult { @@ -41,4 +43,5 @@ export interface FormatOptions { colors?: readonly [PicocolorsColor, PicocolorsColor, PicocolorsColor]; verbose?: boolean; helpUrl?: string; + showPosition?: boolean; } diff --git a/@commitlint/types/src/lint.ts b/@commitlint/types/src/lint.ts index 7640efc362..f694f4e6a2 100644 --- a/@commitlint/types/src/lint.ts +++ b/@commitlint/types/src/lint.ts @@ -42,4 +42,8 @@ export interface LintRuleOutcome { name: string; /** The message returned from the rule, if invalid */ message: string; + /** The start position of the error in the input */ + start?: { line: number; column: number; offset: number }; + /** The end position of the error in the input */ + end?: { line: number; column: number; offset: number }; } diff --git a/docs/api/format.md b/docs/api/format.md index 55a8ead843..5d4d71a0f1 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -60,6 +60,11 @@ type formatOptions = { * URL to print as help for reports with problems **/ helpUrl: string; + + /** + * Show position indicator (~~~) for errors in the input line + **/ + showPosition: boolean = false; } format(report?: Report = {}, options?: formatOptions = {}) => string[]; From 684d0157693b113a7aa92b8ac41d5f267155c107 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 16:13:03 +0100 Subject: [PATCH 02/35] test(lint): add tests for getRulePosition function Add comprehensive tests for the getRulePosition function that calculates error positions for various rule types: - type-enum, type-case, type-max-length - scope-enum, scope-case - subject-max-length, subject-full-stop - header-max-length - body-max-line-length Also test edge cases like rules without position support and valid commits that don't need position data. --- @commitlint/lint/src/lint.test.ts | 279 ++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 0d37aca402..8126e260ca 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -314,3 +314,282 @@ test("passes for async rule", async () => { expect(report.valid).toBe(true); }); + +test("returns position for type-enum error", async () => { + const result = await lint("foo: some message", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].name).toBe("type-enum"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 4, offset: 3 }); +}); + +test("returns position for type-case error", async () => { + const result = await lint("FIX: some message", { + "type-case": [RuleConfigSeverity.Error, "always", "lower-case"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-case"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 4, offset: 3 }); +}); + +test("returns position for type-max-length error", async () => { + const longType = "toolongtype"; + const result = await lint(`${longType}: some message`, { + "type-max-length": [RuleConfigSeverity.Error, "always", 5], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longType.length + 1, + offset: longType.length, + }); +}); + +test("returns position for scope-enum error", async () => { + const result = await lint("feat(badscope): some message", { + "scope-enum": [RuleConfigSeverity.Error, "always", ["cli", "core"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-enum"); + expect(result.errors[0].start).toEqual({ line: 1, column: 6, offset: 5 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 14, offset: 13 }); +}); + +test("returns position for scope-case error", async () => { + const result = await lint("feat(SCOPE): some message", { + "scope-case": [RuleConfigSeverity.Error, "always", "lower-case"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-case"); + expect(result.errors[0].start).toEqual({ line: 1, column: 6, offset: 5 }); + expect(result.errors[0].end).toEqual({ line: 1, column: 11, offset: 10 }); +}); + +test("returns position for subject-max-length error", async () => { + const longSubject = + "this is a very long subject that exceeds the maximum allowed characters"; + const result = await lint(`feat: ${longSubject}`, { + "subject-max-length": [RuleConfigSeverity.Error, "always", 20], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-max-length"); + expect(result.errors[0].start?.line).toBe(1); + expect(result.errors[0].start?.column).toBeGreaterThan(5); + expect(result.errors[0].end?.line).toBe(1); +}); + +test("returns position for subject-full-stop error", async () => { + const result = await lint("feat: some message.", { + "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-full-stop"); + expect(result.errors[0].start?.line).toBe(1); + expect(result.errors[0].start?.column).toBeGreaterThan(5); +}); + +test("returns position for header-max-length error", async () => { + const longHeader = + "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; + const result = await lint(longHeader, { + "header-max-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("header-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longHeader.length + 1, + offset: longHeader.length, + }); +}); + +test("returns position for body-max-line-length error", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit of one hundred characters for each line in the body"; + const result = await lint(`feat: some message\n\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start?.line).toBe(2); +}); + +test("returns no position for rules without position support", async () => { + const result = await lint("somehting #1", { + "references-empty": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("references-empty"); + expect(result.errors[0].start).toBeUndefined(); + expect(result.errors[0].end).toBeUndefined(); +}); + +test("returns correct position for valid commit (no position needed)", async () => { + const result = await lint("feat: add new feature", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); + +test("returns position for subject-full-stop error", async () => { + const result = await lint("feat: some message.", { + "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-full-stop"); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].start?.line).toBe(1); + expect(result.errors[0].end).toBeDefined(); +}); + +test("returns position for header-max-length error", async () => { + const longHeader = + "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; + const result = await lint(longHeader, { + "header-max-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("header-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longHeader.length + 1, + offset: longHeader.length, + }); +}); + +test("returns position for body-max-line-length error", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit"; + const result = await lint(`feat: some message\n\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].start?.line).toBe(2); +}); + +test("returns position for header-max-length error", async () => { + const longHeader = + "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; + const result = await lint(longHeader, { + "header-max-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("header-max-length"); + expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); + expect(result.errors[0].end).toEqual({ + line: 1, + column: longHeader.length + 1, + offset: longHeader.length, + }); +}); + +test("returns position for body-max-line-length error", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit"; + const result = await lint(`feat: some message\n\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 50], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].start?.line).toBe(2); +}); + +test("returns no position for rules without position support", async () => { + const result = await lint("somehting #1", { + "references-empty": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("references-empty"); + expect(result.errors[0].start).toBeUndefined(); + expect(result.errors[0].end).toBeUndefined(); +}); + +test("returns position when type is provided", async () => { + const result = await lint("feat: some message", { + "type-enum": [RuleConfigSeverity.Error, "always", ["bar"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-enum"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("returns position when scope is provided", async () => { + const result = await lint("feat(myscope): some message", { + "scope-enum": [RuleConfigSeverity.Error, "always", ["other"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-enum"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("handles duplicate text in message - uses first occurrence", async () => { + const result = await lint("fix: test test", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); + +test("returns correct position for valid commit (no position needed)", async () => { + const result = await lint("feat: add new feature", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); + +test("returns no position for rules without position support", async () => { + const result = await lint("somehting #1", { + "references-empty": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("references-empty"); + expect(result.errors[0].start).toBeUndefined(); + expect(result.errors[0].end).toBeUndefined(); +}); + +test("returns position when type is provided", async () => { + const result = await lint("feat: some message", { + "type-enum": [RuleConfigSeverity.Error, "always", ["bar"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("type-enum"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("returns position when scope is provided", async () => { + const result = await lint("feat(myscope): some message", { + "scope-enum": [RuleConfigSeverity.Error, "always", ["other"]], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("scope-enum"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("handles duplicate text in message - uses first occurrence", async () => { + const result = await lint("fix: test test", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); + +test("returns correct position for valid commit (no position needed)", async () => { + const result = await lint("feat: add new feature", { + "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); +}); From 083f8fc58e9b7f3cd93fce42281ac7752be0953f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 16:48:12 +0100 Subject: [PATCH 03/35] fix: remove duplicate test names --- @commitlint/format/src/format.ts | 25 ++--- @commitlint/lint/src/lint.test.ts | 155 ------------------------------ @commitlint/lint/src/lint.ts | 7 +- docs/api/format.md | 2 +- 4 files changed, 18 insertions(+), 171 deletions(-) diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index a399035c89..e050718193 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -56,7 +56,7 @@ function formatInput( } const positionIndicator = showPosition - ? getPositionIndicator(errors, input) + ? getPositionIndicator([...errors, ...warnings], input) : undefined; const lines: string[] = [`${decoration} input: ${decoratedInput}`]; @@ -91,16 +91,15 @@ function getPositionIndicator( const headerEndIndex = input.indexOf("\n\n"); if (headerEndIndex === -1) return undefined; - const bodyLineStart = headerEndIndex + 2; - const charsOnLine = input.slice(bodyLineStart).indexOf("\n"); - const lineLength = - charsOnLine === -1 ? input.length - bodyLineStart : charsOnLine; + const bodyText = input.slice(headerEndIndex + 2); + const firstBodyLine = bodyText.split("\n")[0]; + const lineLength = firstBodyLine.length; - if (start.column <= lineLength) { + if (start.column <= lineLength + 1) { const spacesBefore = Math.max(0, start.column - 1); const tildeLength = Math.max( 1, - Math.min(end.column, lineLength) - start.column, + Math.min(end.column - start.column, lineLength - (start.column - 1)), ); indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); @@ -109,12 +108,16 @@ function getPositionIndicator( const footerStartIndex = input.lastIndexOf("\n\n"); if (footerStartIndex === -1) return undefined; - const footerLineStart = footerStartIndex + 2; - const lineLength = input.length - footerLineStart; + const footerText = input.slice(footerStartIndex + 2); + const firstFooterLine = footerText.split("\n")[0]; + const lineLength = firstFooterLine.length; - if (start.column <= lineLength) { + if (start.column <= lineLength + 1) { const spacesBefore = Math.max(0, start.column - 1); - const tildeLength = Math.max(1, end.column - start.column); + const tildeLength = Math.max( + 1, + Math.min(end.column - start.column, lineLength - (start.column - 1)), + ); indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); } diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 8126e260ca..057289a17c 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -438,158 +438,3 @@ test("returns correct position for valid commit (no position needed)", async () expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); - -test("returns position for subject-full-stop error", async () => { - const result = await lint("feat: some message.", { - "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("subject-full-stop"); - expect(result.errors[0].start).toBeDefined(); - expect(result.errors[0].start?.line).toBe(1); - expect(result.errors[0].end).toBeDefined(); -}); - -test("returns position for header-max-length error", async () => { - const longHeader = - "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; - const result = await lint(longHeader, { - "header-max-length": [RuleConfigSeverity.Error, "always", 50], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("header-max-length"); - expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); - expect(result.errors[0].end).toEqual({ - line: 1, - column: longHeader.length + 1, - offset: longHeader.length, - }); -}); - -test("returns position for body-max-line-length error", async () => { - const longBodyLine = - "this is a body line that is way too long and exceeds the maximum allowed character limit"; - const result = await lint(`feat: some message\n\n${longBodyLine}`, { - "body-max-line-length": [RuleConfigSeverity.Error, "always", 50], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("body-max-line-length"); - expect(result.errors[0].start).toBeDefined(); - expect(result.errors[0].start?.line).toBe(2); -}); - -test("returns position for header-max-length error", async () => { - const longHeader = - "feat: this is a very long header that definitely exceeds the maximum allowed character limit for commit messages"; - const result = await lint(longHeader, { - "header-max-length": [RuleConfigSeverity.Error, "always", 50], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("header-max-length"); - expect(result.errors[0].start).toEqual({ line: 1, column: 1, offset: 0 }); - expect(result.errors[0].end).toEqual({ - line: 1, - column: longHeader.length + 1, - offset: longHeader.length, - }); -}); - -test("returns position for body-max-line-length error", async () => { - const longBodyLine = - "this is a body line that is way too long and exceeds the maximum allowed character limit"; - const result = await lint(`feat: some message\n\n${longBodyLine}`, { - "body-max-line-length": [RuleConfigSeverity.Error, "always", 50], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("body-max-line-length"); - expect(result.errors[0].start).toBeDefined(); - expect(result.errors[0].start?.line).toBe(2); -}); - -test("returns no position for rules without position support", async () => { - const result = await lint("somehting #1", { - "references-empty": [RuleConfigSeverity.Error, "always"], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("references-empty"); - expect(result.errors[0].start).toBeUndefined(); - expect(result.errors[0].end).toBeUndefined(); -}); - -test("returns position when type is provided", async () => { - const result = await lint("feat: some message", { - "type-enum": [RuleConfigSeverity.Error, "always", ["bar"]], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("type-enum"); - expect(result.errors[0].start).toBeDefined(); -}); - -test("returns position when scope is provided", async () => { - const result = await lint("feat(myscope): some message", { - "scope-enum": [RuleConfigSeverity.Error, "always", ["other"]], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("scope-enum"); - expect(result.errors[0].start).toBeDefined(); -}); - -test("handles duplicate text in message - uses first occurrence", async () => { - const result = await lint("fix: test test", { - "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], - }); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); -}); - -test("returns correct position for valid commit (no position needed)", async () => { - const result = await lint("feat: add new feature", { - "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], - }); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); -}); - -test("returns no position for rules without position support", async () => { - const result = await lint("somehting #1", { - "references-empty": [RuleConfigSeverity.Error, "always"], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("references-empty"); - expect(result.errors[0].start).toBeUndefined(); - expect(result.errors[0].end).toBeUndefined(); -}); - -test("returns position when type is provided", async () => { - const result = await lint("feat: some message", { - "type-enum": [RuleConfigSeverity.Error, "always", ["bar"]], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("type-enum"); - expect(result.errors[0].start).toBeDefined(); -}); - -test("returns position when scope is provided", async () => { - const result = await lint("feat(myscope): some message", { - "scope-enum": [RuleConfigSeverity.Error, "always", ["other"]], - }); - expect(result.valid).toBe(false); - expect(result.errors[0].name).toBe("scope-enum"); - expect(result.errors[0].start).toBeDefined(); -}); - -test("handles duplicate text in message - uses first occurrence", async () => { - const result = await lint("fix: test test", { - "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], - }); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); -}); - -test("returns correct position for valid commit (no position needed)", async () => { - const result = await lint("feat: add new feature", { - "type-enum": [RuleConfigSeverity.Error, "always", ["feat", "fix"]], - }); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); -}); diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 4246812630..4d59e00d38 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -45,8 +45,8 @@ function getRulePosition( case "type-min-length": case "type-max-length": { if (!parsed.type) return undefined; - const offset = raw.indexOf(parsed.type); - if (offset === -1) return undefined; + if (!raw.startsWith(parsed.type)) return undefined; + const offset = 0; return { start: { line: 1, column: offset + 1, offset }, end: { @@ -315,8 +315,7 @@ export default async function lint( }); const results = (await Promise.all(pendingResults)).filter( - (result): result is LintRuleOutcome => - result !== null && result.message !== undefined, + (result): result is LintRuleOutcome => result !== null, ); const errors = results.filter( diff --git a/docs/api/format.md b/docs/api/format.md index 5d4d71a0f1..5e91888ba2 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -64,7 +64,7 @@ type formatOptions = { /** * Show position indicator (~~~) for errors in the input line **/ - showPosition: boolean = false; + showPosition?: boolean; } format(report?: Report = {}, options?: formatOptions = {}) => string[]; From 43ad3308a85ff2e5144994338c38e9185864aae5 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 17:22:57 +0100 Subject: [PATCH 04/35] fix: improve position indicator for empty field rules and fallback handling - Add fallback positions for type-empty, scope-empty, subject-empty, body-empty, footer-empty - Fix getPositionIndicator to find first problem with position (not just first problem) - Empty field rules now show position indicator pointing to where the field should be --- @commitlint/format/src/format.ts | 8 +++-- @commitlint/lint/src/lint.ts | 61 +++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index e050718193..20cf44037f 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -72,12 +72,14 @@ function getPositionIndicator( problems: FormattableProblem[], input: string, ): string | undefined { - const firstError = problems[0]; - if (!firstError?.start || !firstError?.end) { + const problemWithPosition = problems.find( + (problem) => problem?.start !== undefined && problem?.end !== undefined, + ); + if (!problemWithPosition?.start || !problemWithPosition?.end) { return undefined; } - const { start, end } = firstError; + const { start, end } = problemWithPosition; const padding = " "; const tilde = "~"; diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 4d59e00d38..6b1b104d18 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -44,7 +44,16 @@ function getRulePosition( case "type-case": case "type-min-length": case "type-max-length": { - if (!parsed.type) return undefined; + if (!parsed.type) { + if (ruleName === "type-empty") { + const offset = 0; + return { + start: { line: 1, column: offset + 1, offset }, + end: { line: 1, column: offset + 1, offset }, + }; + } + return undefined; + } if (!raw.startsWith(parsed.type)) return undefined; const offset = 0; return { @@ -62,7 +71,17 @@ function getRulePosition( case "scope-min-length": case "scope-max-length": case "scope-delimiter-style": { - if (!parsed.scope) return undefined; + if (!parsed.scope) { + if (ruleName === "scope-empty") { + const typeEnd = parsed.type ? parsed.type.length : 0; + const offset = typeEnd + 1; + return { + start: { line: 1, column: offset + 1, offset }, + end: { line: 1, column: offset + 2, offset: offset + 1 }, + }; + } + return undefined; + } const scopeStart = raw.indexOf(`(${parsed.scope})`); if (scopeStart === -1) return undefined; return { @@ -80,7 +99,19 @@ function getRulePosition( case "subject-max-length": case "subject-full-stop": case "subject-exclamation-mark": { - if (!parsed.subject) return undefined; + if (!parsed.subject) { + if (ruleName === "subject-empty") { + const typeEnd = parsed.type ? parsed.type.length : 0; + const hasScope = parsed.scope ? parsed.scope.length + 3 : 0; + const separator = ": ".length; + const offset = typeEnd + hasScope + separator; + return { + start: { line: 1, column: offset + 1, offset }, + end: { line: 1, column: offset + 1, offset }, + }; + } + return undefined; + } const subjectStart = raw.indexOf(parsed.subject); if (subjectStart === -1) return undefined; return { @@ -110,7 +141,17 @@ function getRulePosition( case "body-full-stop": case "body-leading-blank": case "body-max-line-length": { - if (!parsed.body) return undefined; + if (!parsed.body) { + if (ruleName === "body-empty") { + const bodyOffset = raw.indexOf("\n\n"); + if (bodyOffset === -1) return undefined; + return { + start: { line: 2, column: 1, offset: bodyOffset + 2 }, + end: { line: 2, column: 1, offset: bodyOffset + 2 }, + }; + } + return undefined; + } const bodyOffset = raw.indexOf("\n\n"); if (bodyOffset === -1) return undefined; const bodyStartOffset = bodyOffset + 2; @@ -128,7 +169,17 @@ function getRulePosition( case "footer-max-length": case "footer-leading-blank": case "footer-max-line-length": { - if (!parsed.footer) return undefined; + if (!parsed.footer) { + if (ruleName === "footer-empty") { + const footerOffset = raw.lastIndexOf("\n\n"); + if (footerOffset === -1) return undefined; + return { + start: { line: 3, column: 1, offset: footerOffset + 2 }, + end: { line: 3, column: 1, offset: footerOffset + 2 }, + }; + } + return undefined; + } const footerOffset = raw.lastIndexOf("\n\n"); if (footerOffset === -1) return undefined; const footerStartOffset = footerOffset + 2; From f87bca610c75f738a10ec14e828926ec5294570f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 17:47:52 +0100 Subject: [PATCH 05/35] fix: resolve remaining PR comments - Fix typo: somehting -> something in test file --- @commitlint/lint/src/lint.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 057289a17c..29d95f30a3 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -169,7 +169,7 @@ test("throws for rule with out of range condition", async () => { }); test("succeds for issue", async () => { - const report = await lint("somehting #1", { + const report = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "never"], }); @@ -177,7 +177,7 @@ test("succeds for issue", async () => { }); test("fails for issue", async () => { - const report = await lint("somehting #1", { + const report = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "always"], }); @@ -186,7 +186,7 @@ test("fails for issue", async () => { test("succeds for custom issue prefix", async () => { const report = await lint( - "somehting REF-1", + "something REF-1", { "references-empty": [RuleConfigSeverity.Error, "never"], }, @@ -202,7 +202,7 @@ test("succeds for custom issue prefix", async () => { test("fails for custom issue prefix", async () => { const report = await lint( - "somehting #1", + "something #1", { "references-empty": [RuleConfigSeverity.Error, "never"], }, @@ -218,7 +218,7 @@ test("fails for custom issue prefix", async () => { test("fails for custom plugin rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "plugin-rule": [RuleConfigSeverity.Error, "never"], }, @@ -238,7 +238,7 @@ test("fails for custom plugin rule", async () => { test("passes for custom plugin rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "plugin-rule": [RuleConfigSeverity.Error, "never"], }, @@ -297,7 +297,7 @@ test("returns original message with commit header, body and footer, parsing comm test("passes for async rule", async () => { const report = await lint( - "somehting #1", + "something #1", { "async-rule": [RuleConfigSeverity.Error, "never"], }, @@ -422,7 +422,7 @@ test("returns position for body-max-line-length error", async () => { }); test("returns no position for rules without position support", async () => { - const result = await lint("somehting #1", { + const result = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "always"], }); expect(result.valid).toBe(false); From 24a9b0283a60430bbc3539b63e694b07c6149962 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 17:49:22 +0100 Subject: [PATCH 06/35] refactor: export shared Position type from @commitlint/types - Export Position interface from @commitlint/types - Use shared Position type in FormattableProblem - Remove duplicate Position interface from lint.ts - Improves maintainability by having a single source of truth --- @commitlint/lint/src/lint.ts | 7 +------ @commitlint/types/src/format.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 6b1b104d18..514946c922 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -10,17 +10,12 @@ import type { BaseRule, RuleType, QualifiedRules, + Position, } from "@commitlint/types"; import { RuleConfigSeverity } from "@commitlint/types"; import { buildCommitMessage } from "./commit-message.js"; -interface Position { - line: number; - column: number; - offset: number; -} - function getRulePosition( ruleName: string, parsed: { diff --git a/@commitlint/types/src/format.ts b/@commitlint/types/src/format.ts index 09960e9e9c..bf7eae426e 100644 --- a/@commitlint/types/src/format.ts +++ b/@commitlint/types/src/format.ts @@ -7,12 +7,18 @@ export type Formatter = ( options: FormatOptions, ) => string; +export interface Position { + line: number; + column: number; + offset: number; +} + export interface FormattableProblem { level: RuleConfigSeverity; name: keyof QualifiedRules; message: string; - start?: { line: number; column: number; offset: number }; - end?: { line: number; column: number; offset: number }; + start?: Position; + end?: Position; } export interface FormattableResult { From 28849c609532391e834baefe493f294c338a8d42 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Fri, 27 Feb 2026 18:35:01 +0100 Subject: [PATCH 07/35] refactor: generalize line handling and fix edge cases - Refactor getPositionIndicator to handle any line number dynamically - Normalize \r\n and \r to \n for cross-platform compatibility - Simplify code by using split approach instead of hard-coded line handling - Works for header, body, footer, and any future line numbers --- @commitlint/format/src/format.ts | 59 ++++++++++---------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 20cf44037f..ee82e4e27b 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -83,49 +83,26 @@ function getPositionIndicator( const padding = " "; const tilde = "~"; - let indicator = ""; - - if (start.line === 1) { - const spacesBefore = Math.max(0, start.column - 1); - const tildeLength = Math.max(1, end.column - start.column); - indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); - } else if (start.line === 2) { - const headerEndIndex = input.indexOf("\n\n"); - if (headerEndIndex === -1) return undefined; - - const bodyText = input.slice(headerEndIndex + 2); - const firstBodyLine = bodyText.split("\n")[0]; - const lineLength = firstBodyLine.length; - - if (start.column <= lineLength + 1) { - const spacesBefore = Math.max(0, start.column - 1); - const tildeLength = Math.max( - 1, - Math.min(end.column - start.column, lineLength - (start.column - 1)), - ); - indicator = - padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); - } - } else if (start.line === 3) { - const footerStartIndex = input.lastIndexOf("\n\n"); - if (footerStartIndex === -1) return undefined; - - const footerText = input.slice(footerStartIndex + 2); - const firstFooterLine = footerText.split("\n")[0]; - const lineLength = firstFooterLine.length; - - if (start.column <= lineLength + 1) { - const spacesBefore = Math.max(0, start.column - 1); - const tildeLength = Math.max( - 1, - Math.min(end.column - start.column, lineLength - (start.column - 1)), - ); - indicator = - padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); - } + + const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const lines = normalizedInput.split("\n"); + const targetLine = lines[start.line - 1]; + + if (!targetLine) { + return undefined; } - return indicator || undefined; + const lineLength = targetLine.length; + const spacesBefore = Math.max(0, start.column - 1); + const tildeLength = Math.max( + 1, + Math.min(end.column - start.column, lineLength - (start.column - 1)), + ); + + const indicator = + padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); + + return indicator; } export function formatResult( From a8f3b912c537903fd285807d2228eb01cfdd7d8f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sun, 1 Mar 2026 13:02:44 +0100 Subject: [PATCH 08/35] feat!: enable position indicator by default - Change position indicator from ~ (multiple) to ^ (single caret) - Enable showPosition by default (breaking change) - Aligns with TypeScript/Rust error formatting - Keep --show-position flag to allow disabling BREAKING CHANGE: position indicator is now shown by default. Output format changed from multiple ~ characters to single ^ caret. Users can disable with --show-position=false. --- @commitlint/cli/src/cli.test.ts | 2 +- @commitlint/cli/src/cli.ts | 1 + @commitlint/format/src/format.test.ts | 18 ++++++++---------- @commitlint/format/src/format.ts | 27 +++++++++++---------------- @commitlint/lint/src/lint.ts | 6 ++++-- docs/api/format.md | 2 +- 6 files changed, 26 insertions(+), 30 deletions(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 94267b013d..d2f87b52be 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -644,7 +644,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] + --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] diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index b188ac9de0..4b83404a8d 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -137,6 +137,7 @@ const cli = yargs(process.argv.slice(2)) }, "show-position": { type: "boolean", + default: true, description: "show position of error in output", }, strict: { diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 161ee111fd..7f3a84e23c 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -328,7 +328,7 @@ test("shows position indicator when showPosition is true and error has position" }, ); - expect(actual).toContain("~~~"); + expect(actual).toContain("^"); }); test("does not show position indicator when showPosition is false", () => { @@ -355,10 +355,10 @@ test("does not show position indicator when showPosition is false", () => { }, ); - expect(actual).not.toContain("~~~"); + expect(actual).not.toContain("^"); }); -test("does not show position indicator when showPosition is not provided", () => { +test("shows position indicator when showPosition is not provided (default)", () => { const actual = format( { results: [ @@ -381,7 +381,7 @@ test("does not show position indicator when showPosition is not provided", () => }, ); - expect(actual).not.toContain("~~~"); + expect(actual).toContain("^"); }); test("does not show position indicator when error has no position", () => { @@ -406,7 +406,7 @@ test("does not show position indicator when error has no position", () => { }, ); - expect(actual).not.toContain("~~~"); + expect(actual).not.toContain("^"); }); test("shows correct position for subject error", () => { @@ -434,10 +434,10 @@ test("shows correct position for subject error", () => { }, ); - expect(actual).toContain("~~~~~~~~"); + expect(actual).toContain("^"); }); -test("shows position indicator with multiple tildes for longer errors", () => { +test("shows position indicator with single caret for longer errors", () => { const actual = format( { results: [ @@ -462,7 +462,5 @@ test("shows position indicator with multiple tildes for longer errors", () => { }, ); - expect(actual).toContain( - "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~", - ); + expect(actual).toContain("^"); }); diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index ee82e4e27b..8c5a96d977 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -38,7 +38,7 @@ function formatInput( result: FormattableResult & WithInput, options: FormatOptions = {}, ): string[] { - const { color: enabled = true, showPosition = false } = options; + const { color: enabled = true, showPosition = true } = options; const { errors = [], warnings = [], input = "" } = result; if (!input) { @@ -47,19 +47,20 @@ function formatInput( const sign = "⧗"; const decoration = enabled ? pc.gray(sign) : sign; + const prefix = `${decoration} input: `; const decoratedInput = enabled ? pc.bold(input) : input; const hasProblems = errors.length > 0 || warnings.length > 0; if (!hasProblems) { - return options.verbose ? [`${decoration} input: ${decoratedInput}`] : []; + return options.verbose ? [`${prefix}${decoratedInput}`] : []; } const positionIndicator = showPosition - ? getPositionIndicator([...errors, ...warnings], input) + ? getPositionIndicator([...errors, ...warnings], input, prefix.length) : undefined; - const lines: string[] = [`${decoration} input: ${decoratedInput}`]; + const lines: string[] = [`${prefix}${decoratedInput}`]; if (positionIndicator) { lines.push(positionIndicator); @@ -71,6 +72,7 @@ function formatInput( function getPositionIndicator( problems: FormattableProblem[], input: string, + prefixLength: number, ): string | undefined { const problemWithPosition = problems.find( (problem) => problem?.start !== undefined && problem?.end !== undefined, @@ -79,28 +81,21 @@ function getPositionIndicator( return undefined; } - const { start, end } = problemWithPosition; - const padding = " "; + const padding = " ".repeat(prefixLength); - const tilde = "~"; + const caret = "^"; const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const lines = normalizedInput.split("\n"); - const targetLine = lines[start.line - 1]; + const targetLine = lines[problemWithPosition.start.line - 1]; if (!targetLine) { return undefined; } - const lineLength = targetLine.length; - const spacesBefore = Math.max(0, start.column - 1); - const tildeLength = Math.max( - 1, - Math.min(end.column - start.column, lineLength - (start.column - 1)), - ); + const spacesBefore = Math.max(0, problemWithPosition.start.column - 1); - const indicator = - padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength); + const indicator = padding + " ".repeat(spacesBefore) + caret; return indicator; } diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 514946c922..21c1cdd603 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -107,8 +107,10 @@ function getRulePosition( } return undefined; } - const subjectStart = raw.indexOf(parsed.subject); - if (subjectStart === -1) return undefined; + const typeEnd = parsed.type ? parsed.type.length : 0; + const hasScope = parsed.scope ? parsed.scope.length + 3 : 0; + const separator = ": ".length; + const subjectStart = typeEnd + hasScope + separator; return { start: { line: 1, column: subjectStart + 1, offset: subjectStart }, end: { diff --git a/docs/api/format.md b/docs/api/format.md index 5e91888ba2..9d4750b840 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -62,7 +62,7 @@ type formatOptions = { helpUrl: string; /** - * Show position indicator (~~~) for errors in the input line + * Show position indicator (^) for errors in the input line **/ showPosition?: boolean; } From 1dbc32240a672bdd15c3a25c38ceacfab5c30915 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:27:02 +0200 Subject: [PATCH 09/35] fix(format): strip ANSI codes when computing position indicator offset prefix.length included the ANSI escape sequences from pc.gray(sign) when colored output is enabled, shifting the caret several columns to the right of the actual error in the default colored CLI output. Compute the visible prefix length from the uncolored sign separately from the displayed colored prefix. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/format/src/format.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 8c5a96d977..9e727d3c0d 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -48,6 +48,7 @@ function formatInput( const sign = "⧗"; const decoration = enabled ? pc.gray(sign) : sign; const prefix = `${decoration} input: `; + const visiblePrefixLength = `${sign} input: `.length; const decoratedInput = enabled ? pc.bold(input) : input; const hasProblems = errors.length > 0 || warnings.length > 0; @@ -57,7 +58,7 @@ function formatInput( } const positionIndicator = showPosition - ? getPositionIndicator([...errors, ...warnings], input, prefix.length) + ? getPositionIndicator([...errors, ...warnings], input, visiblePrefixLength) : undefined; const lines: string[] = [`${prefix}${decoratedInput}`]; From e99b097c5258b255835970aba8038290cd2c4bb6 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:28:14 +0200 Subject: [PATCH 10/35] fix(lint): compute body start line from actual offset Body start position was hard-coded to line 2, but in a standard commit (header\n\nbody) the body starts on line 3. Multi-line bodies made the end position even more wrong. Add an offsetToPosition() helper that derives line/column from the raw offset, and use it for both start and end positions of body rules. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 2 +- @commitlint/lint/src/lint.ts | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 29d95f30a3..22f3028681 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -418,7 +418,7 @@ test("returns position for body-max-line-length error", async () => { }); expect(result.valid).toBe(false); expect(result.errors[0].name).toBe("body-max-line-length"); - expect(result.errors[0].start?.line).toBe(2); + expect(result.errors[0].start?.line).toBe(3); }); test("returns no position for rules without position support", async () => { diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 21c1cdd603..8e289c9735 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -16,6 +16,14 @@ import { RuleConfigSeverity } from "@commitlint/types"; import { buildCommitMessage } from "./commit-message.js"; +function offsetToPosition(raw: string, offset: number): Position { + const before = raw.slice(0, offset); + const newlineCount = (before.match(/\n/g) || []).length; + const lastNewline = before.lastIndexOf("\n"); + const column = lastNewline === -1 ? offset + 1 : offset - lastNewline; + return { line: newlineCount + 1, column, offset }; +} + function getRulePosition( ruleName: string, parsed: { @@ -142,23 +150,18 @@ function getRulePosition( if (ruleName === "body-empty") { const bodyOffset = raw.indexOf("\n\n"); if (bodyOffset === -1) return undefined; - return { - start: { line: 2, column: 1, offset: bodyOffset + 2 }, - end: { line: 2, column: 1, offset: bodyOffset + 2 }, - }; + const start = offsetToPosition(raw, bodyOffset + 2); + return { start, end: start }; } return undefined; } const bodyOffset = raw.indexOf("\n\n"); if (bodyOffset === -1) return undefined; const bodyStartOffset = bodyOffset + 2; + const bodyEndOffset = bodyStartOffset + parsed.body.length; return { - start: { line: 2, column: 1, offset: bodyStartOffset }, - end: { - line: 2, - column: parsed.body.length + 1, - offset: bodyStartOffset + parsed.body.length, - }, + start: offsetToPosition(raw, bodyStartOffset), + end: offsetToPosition(raw, bodyEndOffset), }; } case "footer-empty": From cfe7fddcebe4a5a79ba0f569638270a6e295d50d Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:28:32 +0200 Subject: [PATCH 11/35] fix(lint): compute footer start line from actual offset Footer start position was hard-coded to line 3, but the footer can start anywhere from line 3 onwards depending on body length. Use the shared offsetToPosition() helper to derive the correct line/column. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 8e289c9735..68fb95a560 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -173,23 +173,18 @@ function getRulePosition( if (ruleName === "footer-empty") { const footerOffset = raw.lastIndexOf("\n\n"); if (footerOffset === -1) return undefined; - return { - start: { line: 3, column: 1, offset: footerOffset + 2 }, - end: { line: 3, column: 1, offset: footerOffset + 2 }, - }; + const start = offsetToPosition(raw, footerOffset + 2); + return { start, end: start }; } return undefined; } const footerOffset = raw.lastIndexOf("\n\n"); if (footerOffset === -1) return undefined; const footerStartOffset = footerOffset + 2; + const footerEndOffset = footerStartOffset + parsed.footer.length; return { - start: { line: 3, column: 1, offset: footerStartOffset }, - end: { - line: 3, - column: parsed.footer.length + 1, - offset: footerStartOffset + parsed.footer.length, - }, + start: offsetToPosition(raw, footerStartOffset), + end: offsetToPosition(raw, footerEndOffset), }; } default: From a2cdb1f663f37dcd0b686b555ef33fb4c7bbf9d0 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:29:10 +0200 Subject: [PATCH 12/35] fix(lint): provide position for body-leading-blank when blank is missing The body-leading-blank rule fires precisely when the body exists but the blank separator before it is missing. The previous logic required "\n\n" to exist before returning a position, so the rule that most needs an indicator never got one. Split body-leading-blank into its own case: when the blank exists (rule failed under "never"), point at the blank itself; when the blank is missing (rule failed under "always"), point at the end of the header where the blank should be. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 68fb95a560..661d22c986 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -139,12 +139,21 @@ function getRulePosition( end: { line: 1, column: header.length + 1, offset: header.length }, }; } + case "body-leading-blank": { + if (!parsed.body) return undefined; + const bodyOffset = raw.indexOf("\n\n"); + if (bodyOffset !== -1) { + const start = offsetToPosition(raw, bodyOffset + 1); + return { start, end: start }; + } + const start = offsetToPosition(raw, header.length); + return { start, end: start }; + } case "body-empty": case "body-min-length": case "body-max-length": case "body-case": case "body-full-stop": - case "body-leading-blank": case "body-max-line-length": { if (!parsed.body) { if (ruleName === "body-empty") { From a4d4461c73ba3602b67d9bafa5dad708b192c2bf Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:29:26 +0200 Subject: [PATCH 13/35] fix(lint): provide position for footer-leading-blank when blank is missing Same logic inversion as the body case: footer-leading-blank fires when the footer exists without a preceding blank, but the previous position finder returned undefined in exactly that case. Split footer-leading-blank into its own case. When the blank exists, point at the blank; when it's missing, find the footer in the raw input and point at its start (where the blank should be). Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 661d22c986..8e9c635a3f 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -173,10 +173,21 @@ function getRulePosition( end: offsetToPosition(raw, bodyEndOffset), }; } + case "footer-leading-blank": { + if (!parsed.footer) return undefined; + const footerOffset = raw.lastIndexOf("\n\n"); + if (footerOffset !== -1) { + const start = offsetToPosition(raw, footerOffset + 1); + return { start, end: start }; + } + const footerInRaw = parsed.footer ? raw.indexOf(parsed.footer) : -1; + if (footerInRaw === -1) return undefined; + const start = offsetToPosition(raw, footerInRaw); + return { start, end: start }; + } case "footer-empty": case "footer-min-length": case "footer-max-length": - case "footer-leading-blank": case "footer-max-line-length": { if (!parsed.footer) { if (ruleName === "footer-empty") { From a402e385a0c7852783caeb0933bd9349d65fd167 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:29:47 +0200 Subject: [PATCH 14/35] fix(lint): respect custom parser headerPattern for subject position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subject position calculation hard-coded ": " as the separator between scope and subject, which breaks for any custom parserOpts.headerPattern (the repo's own tests cover patterns like type(scope)-subject). Find the subject by searching for it in the parsed header rather than computing offsets from a hard-coded grammar. For empty-subject cases, fall back to the end of the header — the most we can know without parsing the headerPattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 8e9c635a3f..b15d41d43b 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -104,10 +104,7 @@ function getRulePosition( case "subject-exclamation-mark": { if (!parsed.subject) { if (ruleName === "subject-empty") { - const typeEnd = parsed.type ? parsed.type.length : 0; - const hasScope = parsed.scope ? parsed.scope.length + 3 : 0; - const separator = ": ".length; - const offset = typeEnd + hasScope + separator; + const offset = header.length; return { start: { line: 1, column: offset + 1, offset }, end: { line: 1, column: offset + 1, offset }, @@ -115,10 +112,8 @@ function getRulePosition( } return undefined; } - const typeEnd = parsed.type ? parsed.type.length : 0; - const hasScope = parsed.scope ? parsed.scope.length + 3 : 0; - const separator = ": ".length; - const subjectStart = typeEnd + hasScope + separator; + const subjectStart = header.indexOf(parsed.subject); + if (subjectStart === -1) return undefined; return { start: { line: 1, column: subjectStart + 1, offset: subjectStart }, end: { From c08992ae36709abc582722faabb3561bfb62e349 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sat, 2 May 2026 12:31:58 +0200 Subject: [PATCH 15/35] fix(format): render position indicator under the failing line for multi-line input The position indicator was appended after the entire input string, so body or footer errors landed below the last line of the commit instead of under start.line. As a side effect, continuation lines of multi-line input also weren't aligned with the input column. Split the input into lines, render each with a prefix (or padding for continuations) so multi-line input aligns with the input column, and insert the indicator immediately after the line referenced by the problem's start.line. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/format/src/format.test.ts | 34 ++++++++++++++++- @commitlint/format/src/format.ts | 55 ++++++++++++++++----------- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 7f3a84e23c..8c835d77c1 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -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", ); }); @@ -437,6 +437,38 @@ test("shows correct position for subject error", () => { expect(actual).toContain("^"); }); +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( { diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 9e727d3c0d..02f4c5dbf8 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -49,32 +49,49 @@ function formatInput( const decoration = enabled ? pc.gray(sign) : sign; const prefix = `${decoration} input: `; const visiblePrefixLength = `${sign} input: `.length; + const padding = " ".repeat(visiblePrefixLength); - const decoratedInput = enabled ? pc.bold(input) : input; const hasProblems = errors.length > 0 || warnings.length > 0; + const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const inputLines = normalizedInput.split("\n"); + + const renderedInputLines = inputLines.map((lineText, i) => { + const decoratedLine = enabled ? pc.bold(lineText) : lineText; + const linePrefix = i === 0 ? prefix : padding; + return `${linePrefix}${decoratedLine}`; + }); if (!hasProblems) { - return options.verbose ? [`${prefix}${decoratedInput}`] : []; + return options.verbose ? renderedInputLines : []; } - const positionIndicator = showPosition - ? getPositionIndicator([...errors, ...warnings], input, visiblePrefixLength) + const indicator = showPosition + ? getPositionIndicator( + [...errors, ...warnings], + inputLines, + visiblePrefixLength, + ) : undefined; - const lines: string[] = [`${prefix}${decoratedInput}`]; - - if (positionIndicator) { - lines.push(positionIndicator); + if (!indicator) { + return renderedInputLines; } + const lines: string[] = []; + for (let i = 0; i < renderedInputLines.length; i++) { + lines.push(renderedInputLines[i]); + if (i + 1 === indicator.line) { + lines.push(indicator.text); + } + } return lines; } function getPositionIndicator( problems: FormattableProblem[], - input: string, + inputLines: string[], prefixLength: number, -): string | undefined { +): { text: string; line: number } | undefined { const problemWithPosition = problems.find( (problem) => problem?.start !== undefined && problem?.end !== undefined, ); @@ -82,23 +99,17 @@ function getPositionIndicator( return undefined; } - const padding = " ".repeat(prefixLength); - - const caret = "^"; - - const normalizedInput = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - const lines = normalizedInput.split("\n"); - const targetLine = lines[problemWithPosition.start.line - 1]; - - if (!targetLine) { + const targetLine = inputLines[problemWithPosition.start.line - 1]; + if (targetLine === undefined) { return undefined; } + const padding = " ".repeat(prefixLength); + const caret = "^"; const spacesBefore = Math.max(0, problemWithPosition.start.column - 1); + const text = padding + " ".repeat(spacesBefore) + caret; - const indicator = padding + " ".repeat(spacesBefore) + caret; - - return indicator; + return { text, line: problemWithPosition.start.line }; } export function formatResult( From ab55526dc989a1bc2c6abd98d95df9122415b5a8 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sun, 3 May 2026 13:28:44 +0200 Subject: [PATCH 16/35] test(cli): match each input line individually for stdin-failure output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "should print full commit message when input from stdin fails" test asserted that the raw multi-line input string appears verbatim in the rendered output. After fix(format): render position indicator under the failing line for multi-line input, continuation lines are indented with padding so they align with the input column. The full message is still visible — just no longer a literal substring. Verify the semantic intent (every non-empty input line is rendered) without depending on the exact raw byte sequence. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/cli/src/cli.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index d2f87b52be..8ad7a35494 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -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); }); From cfe9a3b9f49e2430da41a3454414600620afd88c Mon Sep 17 00:00:00 2001 From: escapedcat Date: Sun, 3 May 2026 13:34:51 +0200 Subject: [PATCH 17/35] test: cover position indicator fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add regression tests for four of the position-indicator fixes that landed without direct coverage: - format: indicator alignment is identical with or without color (catches the ANSI-in-prefix.length bug — the colored and plain rendered indicator lines must match) - lint: footer line is computed from the actual offset in a multi-line body, not hard-coded to line 3 - lint: body-leading-blank reports a position even when the blank separator is missing (the case the rule fires for) - lint: footer-leading-blank reports a position when the blank is missing (same inversion as body-leading-blank) Coverage for the custom parserOpts.headerPattern fix is left to external bug reports — the test setup is involved and the affected audience is small. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/format/src/format.test.ts | 26 +++++++++++++++++++++++ @commitlint/lint/src/lint.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 8c835d77c1..11b83d596b 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -437,6 +437,32 @@ test("shows correct position for subject error", () => { 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( { diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 22f3028681..a17edf112f 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,36 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("returns correct footer line for multi-line body", async () => { + const longFooter = + "BREAKING CHANGE: a footer line that is far too long to fit within the configured maximum allowed character limit for the footer"; + const message = `feat: head\n\nbody line 1\nbody line 2\nbody line 3\n\n${longFooter}`; + const result = await lint(message, { + "footer-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("footer-max-line-length"); + expect(result.errors[0].start?.line).toBe(7); +}); + +test("returns position for body-leading-blank when blank is missing", async () => { + const result = await lint("feat: head\nbody content", { + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-leading-blank"); + expect(result.errors[0].start).toBeDefined(); +}); + +test("returns position for footer-leading-blank when blank is missing", async () => { + const result = await lint("feat: head\n\nbody\nBREAKING CHANGE: something", { + "footer-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("footer-leading-blank"); + expect(result.errors[0].start).toBeDefined(); +}); + test("returns no position for rules without position support", async () => { const result = await lint("something #1", { "references-empty": [RuleConfigSeverity.Error, "always"], From e0bad19d97fb334e4323445a2b8a61f668b45dac Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:11:08 +0200 Subject: [PATCH 18/35] fix(lint): use lastIndexOf for subject position header.indexOf(parsed.subject) returns the wrong occurrence when type and subject share text. For headers like "foo: foo" the caret landed on the type rather than the subject for every subject-* rule. Subject sits at the end of the header in every conventional-commits grammar, so lastIndexOf is robust against type/scope text appearing inside the subject string and still works for custom parser presets. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 9 +++++++++ @commitlint/lint/src/lint.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index a17edf112f..9e6a47baf2 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,15 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("returns subject position even when type and subject share text", async () => { + const result = await lint("foo: foo", { + "subject-min-length": [RuleConfigSeverity.Error, "always", 10], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-min-length"); + expect(result.errors[0].start?.column).toBe(6); +}); + test("returns correct footer line for multi-line body", async () => { const longFooter = "BREAKING CHANGE: a footer line that is far too long to fit within the configured maximum allowed character limit for the footer"; diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index b15d41d43b..f79ea757a3 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -112,7 +112,7 @@ function getRulePosition( } return undefined; } - const subjectStart = header.indexOf(parsed.subject); + const subjectStart = header.lastIndexOf(parsed.subject); if (subjectStart === -1) return undefined; return { start: { line: 1, column: subjectStart + 1, offset: subjectStart }, From 87bfde8662bfd46c0d524903da08f7df10fc4c50 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:11:35 +0200 Subject: [PATCH 19/35] fix(lint): locate type in header instead of assuming raw starts with it The previous logic short-circuited via raw.startsWith(parsed.type), so any custom parser preset whose header pattern doesn't begin with the type token (e.g. ----feat: subject) caused type-* rules to return no position. Search for the type within the header so the position is computed correctly regardless of how the headerPattern places the type token. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index f79ea757a3..08fbcd7d55 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -57,8 +57,8 @@ function getRulePosition( } return undefined; } - if (!raw.startsWith(parsed.type)) return undefined; - const offset = 0; + const offset = header.indexOf(parsed.type); + if (offset === -1) return undefined; return { start: { line: 1, column: offset + 1, offset }, end: { From 7d95a7eb96806daffb182fc1935a0beea8f07d06 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:12:03 +0200 Subject: [PATCH 20/35] fix(lint): normalize CRLF before computing rule positions raw.indexOf("\n\n") and raw.lastIndexOf("\n\n") never match "\r\n\r\n", so body-* and footer-* rules returned no position for Windows-style commit messages. Normalize raw line endings to \n once at the top of getRulePosition. The formatter already normalizes the same way before splitting into lines, so positions still align with the rendered input. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 11 +++++++++++ @commitlint/lint/src/lint.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 9e6a47baf2..c22a5eca66 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,17 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("returns body position for CRLF-style commit messages", async () => { + const longBodyLine = + "this is a body line that is way too long and exceeds the maximum allowed character limit of one hundred characters for each line in the body"; + const result = await lint(`feat: head\r\n\r\n${longBodyLine}`, { + "body-max-line-length": [RuleConfigSeverity.Error, "always", 80], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-max-line-length"); + expect(result.errors[0].start).toBeDefined(); +}); + test("returns subject position even when type and subject share text", async () => { const result = await lint("foo: foo", { "subject-min-length": [RuleConfigSeverity.Error, "always", 10], diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 08fbcd7d55..89794a56f0 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -36,7 +36,7 @@ function getRulePosition( footer?: string | null; }, ): { start: Position; end: Position } | undefined { - const raw = parsed.raw || ""; + const raw = (parsed.raw || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); if (!raw) return undefined; const header = parsed.header || ""; From 94ca03108760f2d6226c1e9da28f63c6300fb321 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:12:38 +0200 Subject: [PATCH 21/35] fix(lint): point subject-exclamation-mark caret at the bang position subject-exclamation-mark validates the breaking-change "!" before the colon, not the subject text itself. Grouping it with the subject-* rules made the caret land on the subject string, which is misleading output for the rule that fires when "!" is present or missing. Split it into its own case: when "!" is present in the header point at it; otherwise point at the colon (where "!" would belong). Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 89794a56f0..a3aae2383e 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -96,12 +96,26 @@ function getRulePosition( }, }; } + case "subject-exclamation-mark": { + const bangIndex = header.indexOf("!"); + if (bangIndex !== -1) { + return { + start: { line: 1, column: bangIndex + 1, offset: bangIndex }, + end: { line: 1, column: bangIndex + 2, offset: bangIndex + 1 }, + }; + } + const colonIndex = header.indexOf(":"); + if (colonIndex === -1) return undefined; + return { + start: { line: 1, column: colonIndex + 1, offset: colonIndex }, + end: { line: 1, column: colonIndex + 1, offset: colonIndex }, + }; + } case "subject-empty": case "subject-case": case "subject-min-length": case "subject-max-length": - case "subject-full-stop": - case "subject-exclamation-mark": { + case "subject-full-stop": { if (!parsed.subject) { if (ruleName === "subject-empty") { const offset = header.length; From 89612ee63cf3e66d331213c7c1b0808c2d6d6974 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:13:08 +0200 Subject: [PATCH 22/35] docs(format): document start/end problem fields for position indicator The format API docs added showPosition but didn't mention that callers must populate start/end on each problem for the caret to render. Programmatic users of @commitlint/format had no way to know which fields wire up the new behavior. Document both fields on the Problem type, including a note that they are required for the indicator to render. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/format.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/api/format.md b/docs/api/format.md index 9d4750b840..c847276bc0 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -24,6 +24,17 @@ type Problem = { * Message to print */ message: string; + /* + * Start position of the problem in the input. + * Required (together with `end`) for the position + * indicator (`^`) to render under the input line + * when `showPosition` is enabled. + */ + start?: { line: number; column: number; offset: number }; + /* + * End position of the problem in the input. + */ + end?: { line: number; column: number; offset: number }; } type Report = { From 32ff054dff9aaf5749312b6b85a7a2549330951e Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:14:22 +0200 Subject: [PATCH 23/35] test(cli): add integration coverage for --show-position default and opt-out The new --show-position flag was only covered by the help-snapshot assertion, which catches changes to the help text but not regressions in the yargs default wiring or formatter integration. Add two CLI-level tests: - default invocation prints the caret on failure - --no-show-position suppresses the caret Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/cli/src/cli.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 8ad7a35494..62b0a52ac4 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -193,6 +193,24 @@ test("should fail for input from stdin with rule from rc", async () => { expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); +test("should print position indicator caret by default on failure", async () => { + const cwd = await gitBootstrap("fixtures/simple"); + const result = cli(["--color=false"], { cwd })("foo: bar"); + const output = await result; + expect(output.stdout).toContain("^"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + +test("should suppress position indicator when --no-show-position is set", async () => { + const cwd = await gitBootstrap("fixtures/simple"); + const result = cli(["--color=false", "--no-show-position"], { cwd })( + "foo: bar", + ); + const output = await result; + expect(output.stdout).not.toContain("^"); + expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); +}); + test("should work with --config option", async () => { const file = "config/commitlint.config.js"; const cwd = await gitBootstrap("fixtures/specify-config-file"); From dd59f4925413583c63714cf531ee7e5f36a7746e Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:36:34 +0200 Subject: [PATCH 24/35] fix(lint): pin subject-exclamation-mark caret to the bang adjacent to colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit header.indexOf("!") matched any "!" in the header, including ones inside the subject text. For "feat: hello! world" the caret pointed at the bang inside the subject rather than at the colon position where the breaking-change marker is missing. Find the colon first, then check whether "!" sits immediately before it — that's the only position where the conventional-commits breaking-change marker is valid. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 13 +++++++++++++ @commitlint/lint/src/lint.ts | 8 ++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index c22a5eca66..f477bb3e9b 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,19 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("subject-exclamation-mark caret ignores bangs inside the subject", async () => { + // Header has "!" inside the subject text; the rule fires under "always" + // because there's no breaking-change "!" before the colon. The caret + // should land on the colon (where "!" should go), not on the bang in + // "hello!". + const result = await lint("feat: hello! world", { + "subject-exclamation-mark": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-exclamation-mark"); + expect(result.errors[0].start?.column).toBe(5); +}); + test("returns body position for CRLF-style commit messages", async () => { const longBodyLine = "this is a body line that is way too long and exceeds the maximum allowed character limit of one hundred characters for each line in the body"; diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index a3aae2383e..f074256a66 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -97,15 +97,15 @@ function getRulePosition( }; } case "subject-exclamation-mark": { - const bangIndex = header.indexOf("!"); - if (bangIndex !== -1) { + const colonIndex = header.indexOf(":"); + if (colonIndex === -1) return undefined; + if (colonIndex > 0 && header[colonIndex - 1] === "!") { + const bangIndex = colonIndex - 1; return { start: { line: 1, column: bangIndex + 1, offset: bangIndex }, end: { line: 1, column: bangIndex + 2, offset: bangIndex + 1 }, }; } - const colonIndex = header.indexOf(":"); - if (colonIndex === -1) return undefined; return { start: { line: 1, column: colonIndex + 1, offset: colonIndex }, end: { line: 1, column: colonIndex + 1, offset: colonIndex }, From fde3db129d671d5f8fe9a0a710294f085172d472 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:38:20 +0200 Subject: [PATCH 25/35] fix(lint): point leading-blank carets at the actual section boundary Both body-leading-blank and footer-leading-blank had a fallback that searched raw for "\n\n", which can match a paragraph break *inside* the body and put the caret on the wrong line. The rule fires for the leading blank specifically, so the caret should always point at the section boundary. - body-leading-blank: point at header.length (header/body boundary). - footer-leading-blank: locate the footer in raw and point at the character immediately before it (body/footer boundary). Tighten the existing tests to assert the expected offset rather than just toBeDefined, plus add a regression test for a body that contains its own "\n\n" paragraph break. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 27 ++++++++++++++++++++++----- @commitlint/lint/src/lint.ts | 23 ++++++++++------------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index f477bb3e9b..0694d67080 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -466,22 +466,39 @@ test("returns correct footer line for multi-line body", async () => { expect(result.errors[0].start?.line).toBe(7); }); -test("returns position for body-leading-blank when blank is missing", async () => { +test("returns position for body-leading-blank pointing at end of header", async () => { const result = await lint("feat: head\nbody content", { "body-leading-blank": [RuleConfigSeverity.Error, "always"], }); expect(result.valid).toBe(false); expect(result.errors[0].name).toBe("body-leading-blank"); - expect(result.errors[0].start).toBeDefined(); + // Header "feat: head" is 10 chars; offset 10 is the line break after it. + expect(result.errors[0].start?.offset).toBe(10); }); -test("returns position for footer-leading-blank when blank is missing", async () => { - const result = await lint("feat: head\n\nbody\nBREAKING CHANGE: something", { +test("body-leading-blank position ignores paragraph breaks inside body", async () => { + // raw contains a "\n\n" inside the body, but the rule fires because + // the blank between header and body is missing. Caret must point at + // the header/body boundary, not the in-body paragraph break. + const result = await lint("feat: head\nfirst paragraph\n\nsecond paragraph", { + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-leading-blank"); + expect(result.errors[0].start?.offset).toBe(10); +}); + +test("returns position for footer-leading-blank pointing right before footer", async () => { + const message = "feat: head\n\nbody\nBREAKING CHANGE: something"; + const result = await lint(message, { "footer-leading-blank": [RuleConfigSeverity.Error, "always"], }); expect(result.valid).toBe(false); expect(result.errors[0].name).toBe("footer-leading-blank"); - expect(result.errors[0].start).toBeDefined(); + // Footer starts at index of "BREAKING CHANGE"; caret points at the + // character immediately before it. + const expected = message.indexOf("BREAKING CHANGE") - 1; + expect(result.errors[0].start?.offset).toBe(expected); }); test("returns no position for rules without position support", async () => { diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index f074256a66..0ad7885eeb 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -150,11 +150,9 @@ function getRulePosition( } case "body-leading-blank": { if (!parsed.body) return undefined; - const bodyOffset = raw.indexOf("\n\n"); - if (bodyOffset !== -1) { - const start = offsetToPosition(raw, bodyOffset + 1); - return { start, end: start }; - } + // Point at the header/body boundary in both directions: + // for "always" failures it's where the missing blank should be, + // for "never" failures it's the existing blank line. const start = offsetToPosition(raw, header.length); return { start, end: start }; } @@ -184,14 +182,13 @@ function getRulePosition( } case "footer-leading-blank": { if (!parsed.footer) return undefined; - const footerOffset = raw.lastIndexOf("\n\n"); - if (footerOffset !== -1) { - const start = offsetToPosition(raw, footerOffset + 1); - return { start, end: start }; - } - const footerInRaw = parsed.footer ? raw.indexOf(parsed.footer) : -1; - if (footerInRaw === -1) return undefined; - const start = offsetToPosition(raw, footerInRaw); + // Point at the body/footer boundary. Find the footer in raw + // and aim at the character immediately before it — that's the + // existing blank or the missing-blank position depending on + // which direction the rule failed in. + const footerStart = raw.indexOf(parsed.footer); + if (footerStart <= 0) return undefined; + const start = offsetToPosition(raw, footerStart - 1); return { start, end: start }; } case "footer-empty": From 0884f2504117d9ccd491bbfc4fbe8c9b6696c1a6 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:38:55 +0200 Subject: [PATCH 26/35] fix(lint): provide body-empty position for header-only commits When a commit has no body section at all, raw.indexOf("\n\n") returned -1 and the rule reported no position even though it had fired. Fall back to the end of the header so the caret lands where the missing body would belong. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 10 ++++++++++ @commitlint/lint/src/lint.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 0694d67080..5ce94aa27f 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,16 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("body-empty returns position for header-only commits", async () => { + const result = await lint("feat: head", { + "body-empty": [RuleConfigSeverity.Error, "never"], + }); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("body-empty"); + // "feat: head".length === 10 + expect(result.errors[0].start?.offset).toBe(10); +}); + test("subject-exclamation-mark caret ignores bangs inside the subject", async () => { // Header has "!" inside the subject text; the rule fires under "always" // because there's no breaking-change "!" before the colon. The caret diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 0ad7885eeb..5be01cab85 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -165,8 +165,10 @@ function getRulePosition( if (!parsed.body) { if (ruleName === "body-empty") { const bodyOffset = raw.indexOf("\n\n"); - if (bodyOffset === -1) return undefined; - const start = offsetToPosition(raw, bodyOffset + 2); + // For header-only commits there is no "\n\n" — the missing + // body would belong right after the header. + const bodyStart = bodyOffset === -1 ? header.length : bodyOffset + 2; + const start = offsetToPosition(raw, bodyStart); return { start, end: start }; } return undefined; From 1153f823a77b60b7c372eea7a159c8fdff5842a9 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:39:33 +0200 Subject: [PATCH 27/35] fix(lint): clamp body/footer end offset to raw.length parsed.body / parsed.footer may have been trimmed or normalized by the parser, so bodyStart + parsed.body.length can exceed the actual raw range. Clamp to raw.length so the end Position never points past the actual input. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 5be01cab85..0b435a5d8c 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -176,7 +176,12 @@ function getRulePosition( const bodyOffset = raw.indexOf("\n\n"); if (bodyOffset === -1) return undefined; const bodyStartOffset = bodyOffset + 2; - const bodyEndOffset = bodyStartOffset + parsed.body.length; + // Clamp end to raw.length: parsed.body may be trimmed/normalized + // so start + body.length can exceed the actual raw range. + const bodyEndOffset = Math.min( + bodyStartOffset + parsed.body.length, + raw.length, + ); return { start: offsetToPosition(raw, bodyStartOffset), end: offsetToPosition(raw, bodyEndOffset), @@ -209,7 +214,10 @@ function getRulePosition( const footerOffset = raw.lastIndexOf("\n\n"); if (footerOffset === -1) return undefined; const footerStartOffset = footerOffset + 2; - const footerEndOffset = footerStartOffset + parsed.footer.length; + const footerEndOffset = Math.min( + footerStartOffset + parsed.footer.length, + raw.length, + ); return { start: offsetToPosition(raw, footerStartOffset), end: offsetToPosition(raw, footerEndOffset), From 54fc1d0c9ffe738d4f8aa655e07aa6c4ecba0d8f Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:40:31 +0200 Subject: [PATCH 28/35] refactor(types): adopt Position in LintRuleOutcome and document units The earlier "export shared Position type" refactor missed the inline { line, column, offset } shapes in LintRuleOutcome. Replace them with the exported Position type so the public surface uses a single named shape. Also document the units on Position itself: line/column are 1-indexed, offset is 0-indexed character (not display-width) positions. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/types/src/format.ts | 3 +++ @commitlint/types/src/lint.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/@commitlint/types/src/format.ts b/@commitlint/types/src/format.ts index bf7eae426e..21edc1d969 100644 --- a/@commitlint/types/src/format.ts +++ b/@commitlint/types/src/format.ts @@ -8,8 +8,11 @@ export type Formatter = ( ) => string; export interface Position { + /** 1-indexed line in the input. */ line: number; + /** 1-indexed character column on the line (not display width). */ column: number; + /** 0-indexed character offset from the start of the input. */ offset: number; } diff --git a/@commitlint/types/src/lint.ts b/@commitlint/types/src/lint.ts index f694f4e6a2..c8bf08b81b 100644 --- a/@commitlint/types/src/lint.ts +++ b/@commitlint/types/src/lint.ts @@ -1,4 +1,5 @@ import type { ParserOptions as Options } from "conventional-commits-parser"; +import { Position } from "./format.js"; import { IsIgnoredOptions } from "./is-ignored.js"; import { PluginRecords } from "./load.js"; import { RuleConfigSeverity, RuleConfigTuple } from "./rules.js"; @@ -43,7 +44,7 @@ export interface LintRuleOutcome { /** The message returned from the rule, if invalid */ message: string; /** The start position of the error in the input */ - start?: { line: number; column: number; offset: number }; + start?: Position; /** The end position of the error in the input */ - end?: { line: number; column: number; offset: number }; + end?: Position; } From 01945ae27590fd6c22ea4eca90fa9f1123edcb68 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:41:19 +0200 Subject: [PATCH 29/35] docs(cli): add --show-position to the CLI reference The new --show-position flag was wired up in code and the help snapshot but missed in docs/reference/cli.md. Add it to the options listing so the rendered docs match the live --help output. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reference/cli.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index efc0656a62..be70d36594 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -42,6 +42,7 @@ Options: 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 From 3a9ec8714cb6c797e023c0e27766a542ff1b5618 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:42:21 +0200 Subject: [PATCH 30/35] test(lint): add subject position test for custom parserOpts headerPattern The fix that switched type/subject lookup from hard-coded offsets to header-aware searching wasn't covered by a positive position assertion against a non-default parserOpts.headerPattern. Add a test using the type-scope-subject grammar (the same one already used elsewhere in the suite) to lock in the parser-aware behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/@commitlint/lint/src/lint.test.ts b/@commitlint/lint/src/lint.test.ts index 5ce94aa27f..8c4525caec 100644 --- a/@commitlint/lint/src/lint.test.ts +++ b/@commitlint/lint/src/lint.test.ts @@ -421,6 +421,27 @@ test("returns position for body-max-line-length error", async () => { expect(result.errors[0].start?.line).toBe(3); }); +test("returns subject position with custom parserOpts.headerPattern", async () => { + // type-scope-subject grammar (non-default headerPattern). Subject + // position must be located by searching the header rather than + // computed from a hard-coded ": " separator. + const result = await lint( + "foo-bar", + { + "subject-min-length": [RuleConfigSeverity.Error, "always", 10], + }, + { + parserOpts: { + headerPattern: /^(\w*)(?:\((.*)\))?-(.*)$/, + }, + }, + ); + expect(result.valid).toBe(false); + expect(result.errors[0].name).toBe("subject-min-length"); + // "foo-bar": "bar" starts at offset 4 + expect(result.errors[0].start?.column).toBe(5); +}); + test("body-empty returns position for header-only commits", async () => { const result = await lint("feat: head", { "body-empty": [RuleConfigSeverity.Error, "never"], From 79451a7ac0266906aa70fea2834c24145efaf326 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 13:42:59 +0200 Subject: [PATCH 31/35] test(cli): assert full-message reprint preserves input line order The previous loop did independent toContain checks, which would incorrectly pass for scrambled output. Walk the lines forward through stdout and require each next line to appear at or after the previous one's end, so the test fails if the formatter ever reorders the input lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/cli/src/cli.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 62b0a52ac4..583534f92b 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -513,8 +513,13 @@ 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; + // Each input line must appear in stdout *in the original order* — + // independent toContain checks would let scrambled output pass. + let cursor = 0; for (const line of input.split("\n").filter((l) => l.length > 0)) { - expect(output.stdout).toContain(line); + const found = output.stdout.indexOf(line, cursor); + expect(found).toBeGreaterThanOrEqual(cursor); + cursor = found + line.length; } expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); From 834e8b3ed6a625aa547ac1bffcd8b2d14cf59dec Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 14:56:28 +0200 Subject: [PATCH 32/35] refactor(lint): simplify getRulePosition with field-level helpers Replace the per-rule switch with a small set of field span helpers plus three exact-character special cases (subject-exclamation-mark, body-leading-blank, footer-leading-blank). Field is inferred from the rule-name prefix, which removes the per-new-rule maintenance tax and keeps behavior consistent across the type/scope/subject/header/body/ footer rule families. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/lint/src/lint.ts | 284 ++++++++++++++--------------------- 1 file changed, 113 insertions(+), 171 deletions(-) diff --git a/@commitlint/lint/src/lint.ts b/@commitlint/lint/src/lint.ts index 0b435a5d8c..ddab206b16 100644 --- a/@commitlint/lint/src/lint.ts +++ b/@commitlint/lint/src/lint.ts @@ -24,210 +24,152 @@ function offsetToPosition(raw: string, offset: number): Position { return { line: newlineCount + 1, column, offset }; } -function getRulePosition( - ruleName: string, - parsed: { - raw?: string; - header?: string | null; - type?: string | null; - subject?: string | null; - scope?: string | null; - body?: string | null; - footer?: string | null; - }, -): { start: Position; end: Position } | undefined { - const raw = (parsed.raw || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!raw) return undefined; +function span( + raw: string, + startOffset: number, + endOffset: number, +): { start: Position; end: Position } { + return { + start: offsetToPosition(raw, startOffset), + end: offsetToPosition(raw, Math.min(endOffset, raw.length)), + }; +} - const header = parsed.header || ""; +function point( + raw: string, + offset: number, +): { start: Position; end: Position } { + const p = offsetToPosition(raw, offset); + return { start: p, end: p }; +} - switch (ruleName) { - case "type-enum": - case "type-empty": - case "type-case": - case "type-min-length": - case "type-max-length": { +type ParsedCommit = { + raw?: string; + header?: string | null; + type?: string | null; + subject?: string | null; + scope?: string | null; + body?: string | null; + footer?: string | null; +}; + +function ruleField( + ruleName: string, +): "type" | "scope" | "subject" | "header" | "body" | "footer" | undefined { + if (ruleName.startsWith("type-")) return "type"; + if (ruleName.startsWith("scope-")) return "scope"; + if (ruleName.startsWith("subject-")) return "subject"; + if (ruleName.startsWith("header-")) return "header"; + if (ruleName.startsWith("body-")) return "body"; + if (ruleName.startsWith("footer-")) return "footer"; + return undefined; +} + +function fieldSpan( + field: NonNullable>, + ruleName: string, + parsed: ParsedCommit, + raw: string, + header: string, +): { start: Position; end: Position } | undefined { + switch (field) { + case "type": { if (!parsed.type) { - if (ruleName === "type-empty") { - const offset = 0; - return { - start: { line: 1, column: offset + 1, offset }, - end: { line: 1, column: offset + 1, offset }, - }; - } - return undefined; + return ruleName === "type-empty" ? point(raw, 0) : undefined; } const offset = header.indexOf(parsed.type); if (offset === -1) return undefined; - return { - start: { line: 1, column: offset + 1, offset }, - end: { - line: 1, - column: offset + parsed.type.length + 1, - offset: offset + parsed.type.length, - }, - }; + return span(raw, offset, offset + parsed.type.length); } - case "scope-enum": - case "scope-empty": - case "scope-case": - case "scope-min-length": - case "scope-max-length": - case "scope-delimiter-style": { + case "scope": { if (!parsed.scope) { if (ruleName === "scope-empty") { const typeEnd = parsed.type ? parsed.type.length : 0; - const offset = typeEnd + 1; - return { - start: { line: 1, column: offset + 1, offset }, - end: { line: 1, column: offset + 2, offset: offset + 1 }, - }; + return point(raw, typeEnd + 1); } return undefined; } - const scopeStart = raw.indexOf(`(${parsed.scope})`); - if (scopeStart === -1) return undefined; - return { - start: { line: 1, column: scopeStart + 2, offset: scopeStart + 1 }, - end: { - line: 1, - column: scopeStart + parsed.scope.length + 2, - offset: scopeStart + parsed.scope.length + 1, - }, - }; - } - case "subject-exclamation-mark": { - const colonIndex = header.indexOf(":"); - if (colonIndex === -1) return undefined; - if (colonIndex > 0 && header[colonIndex - 1] === "!") { - const bangIndex = colonIndex - 1; - return { - start: { line: 1, column: bangIndex + 1, offset: bangIndex }, - end: { line: 1, column: bangIndex + 2, offset: bangIndex + 1 }, - }; - } - return { - start: { line: 1, column: colonIndex + 1, offset: colonIndex }, - end: { line: 1, column: colonIndex + 1, offset: colonIndex }, - }; + const parenStart = header.indexOf(`(${parsed.scope})`); + const offset = + parenStart >= 0 ? parenStart + 1 : header.lastIndexOf(parsed.scope); + if (offset === -1) return undefined; + return span(raw, offset, offset + parsed.scope.length); } - case "subject-empty": - case "subject-case": - case "subject-min-length": - case "subject-max-length": - case "subject-full-stop": { + case "subject": { if (!parsed.subject) { - if (ruleName === "subject-empty") { - const offset = header.length; - return { - start: { line: 1, column: offset + 1, offset }, - end: { line: 1, column: offset + 1, offset }, - }; - } - return undefined; + return ruleName === "subject-empty" + ? point(raw, header.length) + : undefined; } - const subjectStart = header.lastIndexOf(parsed.subject); - if (subjectStart === -1) return undefined; - return { - start: { line: 1, column: subjectStart + 1, offset: subjectStart }, - end: { - line: 1, - column: subjectStart + parsed.subject.length + 1, - offset: subjectStart + parsed.subject.length, - }, - }; + const offset = header.lastIndexOf(parsed.subject); + if (offset === -1) return undefined; + return span(raw, offset, offset + parsed.subject.length); } - case "header-min-length": - case "header-max-length": - case "header-case": - case "header-full-stop": - case "header-trim": { + case "header": { if (!header) return undefined; - return { - start: { line: 1, column: 1, offset: 0 }, - end: { line: 1, column: header.length + 1, offset: header.length }, - }; - } - case "body-leading-blank": { - if (!parsed.body) return undefined; - // Point at the header/body boundary in both directions: - // for "always" failures it's where the missing blank should be, - // for "never" failures it's the existing blank line. - const start = offsetToPosition(raw, header.length); - return { start, end: start }; + return span(raw, 0, header.length); } - case "body-empty": - case "body-min-length": - case "body-max-length": - case "body-case": - case "body-full-stop": - case "body-max-line-length": { + case "body": { + const blank = raw.indexOf("\n\n"); if (!parsed.body) { if (ruleName === "body-empty") { - const bodyOffset = raw.indexOf("\n\n"); - // For header-only commits there is no "\n\n" — the missing - // body would belong right after the header. - const bodyStart = bodyOffset === -1 ? header.length : bodyOffset + 2; - const start = offsetToPosition(raw, bodyStart); - return { start, end: start }; + return point(raw, blank === -1 ? header.length : blank + 2); } return undefined; } - const bodyOffset = raw.indexOf("\n\n"); - if (bodyOffset === -1) return undefined; - const bodyStartOffset = bodyOffset + 2; - // Clamp end to raw.length: parsed.body may be trimmed/normalized - // so start + body.length can exceed the actual raw range. - const bodyEndOffset = Math.min( - bodyStartOffset + parsed.body.length, - raw.length, - ); - return { - start: offsetToPosition(raw, bodyStartOffset), - end: offsetToPosition(raw, bodyEndOffset), - }; - } - case "footer-leading-blank": { - if (!parsed.footer) return undefined; - // Point at the body/footer boundary. Find the footer in raw - // and aim at the character immediately before it — that's the - // existing blank or the missing-blank position depending on - // which direction the rule failed in. - const footerStart = raw.indexOf(parsed.footer); - if (footerStart <= 0) return undefined; - const start = offsetToPosition(raw, footerStart - 1); - return { start, end: start }; + if (blank === -1) return undefined; + const start = blank + 2; + return span(raw, start, start + parsed.body.length); } - case "footer-empty": - case "footer-min-length": - case "footer-max-length": - case "footer-max-line-length": { + case "footer": { + const blank = raw.lastIndexOf("\n\n"); if (!parsed.footer) { - if (ruleName === "footer-empty") { - const footerOffset = raw.lastIndexOf("\n\n"); - if (footerOffset === -1) return undefined; - const start = offsetToPosition(raw, footerOffset + 2); - return { start, end: start }; + if (ruleName === "footer-empty" && blank !== -1) { + return point(raw, blank + 2); } return undefined; } - const footerOffset = raw.lastIndexOf("\n\n"); - if (footerOffset === -1) return undefined; - const footerStartOffset = footerOffset + 2; - const footerEndOffset = Math.min( - footerStartOffset + parsed.footer.length, - raw.length, - ); - return { - start: offsetToPosition(raw, footerStartOffset), - end: offsetToPosition(raw, footerEndOffset), - }; + if (blank === -1) return undefined; + const start = blank + 2; + return span(raw, start, start + parsed.footer.length); } - default: - return undefined; } } +function getRulePosition( + ruleName: string, + parsed: ParsedCommit, +): { start: Position; end: Position } | undefined { + const raw = (parsed.raw || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!raw) return undefined; + + const header = parsed.header || ""; + + // Boundary special cases — exact-character precision matters. + if (ruleName === "subject-exclamation-mark") { + const colonIndex = header.indexOf(":"); + if (colonIndex === -1) return undefined; + const bangIndex = + colonIndex > 0 && header[colonIndex - 1] === "!" + ? colonIndex - 1 + : colonIndex; + return point(raw, bangIndex); + } + if (ruleName === "body-leading-blank") { + return parsed.body ? point(raw, header.length) : undefined; + } + if (ruleName === "footer-leading-blank") { + if (!parsed.footer) return undefined; + const footerStart = raw.indexOf(parsed.footer); + if (footerStart <= 0) return undefined; + return point(raw, footerStart - 1); + } + + const field = ruleField(ruleName); + if (!field) return undefined; + return fieldSpan(field, ruleName, parsed, raw, header); +} + export default async function lint( message: string, rawRulesConfig?: QualifiedRules, From a1e3f5c25ae43ad4f1a23cfb00a2886d46468487 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 14:56:41 +0200 Subject: [PATCH 33/35] feat(cli): make --show-position opt-in (default off) Reverts the default-on rollout from "feat!: enable position indicator by default". The output-format change now ships behind --show-position so downstream consumers that parse commitlint output are not disrupted. The flag and its --no-show-position counterpart remain available; the caret renders identically when enabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/cli/src/cli.test.ts | 14 ++++++-------- @commitlint/cli/src/cli.ts | 2 +- @commitlint/format/src/format.test.ts | 4 ++-- @commitlint/format/src/format.ts | 2 +- docs/api/format.md | 3 ++- docs/reference/cli.md | 2 +- 6 files changed, 13 insertions(+), 14 deletions(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index 583534f92b..a682f1280e 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -193,21 +193,19 @@ test("should fail for input from stdin with rule from rc", async () => { expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); -test("should print position indicator caret by default on failure", async () => { +test("should hide position indicator caret by default on failure", async () => { const cwd = await gitBootstrap("fixtures/simple"); const result = cli(["--color=false"], { cwd })("foo: bar"); const output = await result; - expect(output.stdout).toContain("^"); + expect(output.stdout).not.toContain("^"); expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); -test("should suppress position indicator when --no-show-position is set", async () => { +test("should show position indicator when --show-position is set", async () => { const cwd = await gitBootstrap("fixtures/simple"); - const result = cli(["--color=false", "--no-show-position"], { cwd })( - "foo: bar", - ); + const result = cli(["--color=false", "--show-position"], { cwd })("foo: bar"); const output = await result; - expect(output.stdout).not.toContain("^"); + expect(output.stdout).toContain("^"); expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); @@ -669,7 +667,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] + --show-position show position of error in output [boolean] [default: false] -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] diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index 4b83404a8d..605aa374de 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -137,7 +137,7 @@ const cli = yargs(process.argv.slice(2)) }, "show-position": { type: "boolean", - default: true, + default: false, description: "show position of error in output", }, strict: { diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 11b83d596b..83e6b397e5 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -358,7 +358,7 @@ test("does not show position indicator when showPosition is false", () => { expect(actual).not.toContain("^"); }); -test("shows position indicator when showPosition is not provided (default)", () => { +test("hides position indicator when showPosition is not provided (default)", () => { const actual = format( { results: [ @@ -381,7 +381,7 @@ test("shows position indicator when showPosition is not provided (default)", () }, ); - expect(actual).toContain("^"); + expect(actual).not.toContain("^"); }); test("does not show position indicator when error has no position", () => { diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 02f4c5dbf8..3cfea8a066 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -38,7 +38,7 @@ function formatInput( result: FormattableResult & WithInput, options: FormatOptions = {}, ): string[] { - const { color: enabled = true, showPosition = true } = options; + const { color: enabled = true, showPosition = false } = options; const { errors = [], warnings = [], input = "" } = result; if (!input) { diff --git a/docs/api/format.md b/docs/api/format.md index c847276bc0..80b14289b3 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -73,7 +73,8 @@ type formatOptions = { helpUrl: string; /** - * Show position indicator (^) for errors in the input line + * Show position indicator (^) for errors in the input line. + * Defaults to `false`. **/ showPosition?: boolean; } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index be70d36594..e072e1efa4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -42,7 +42,7 @@ Options: edit=false [string] -V, --verbose enable verbose output for reports without problems [boolean] - --show-position show position of error in output [boolean] [default: true] + --show-position show position of error in output [boolean] [default: false] -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 From 037a6a93215c5191342ee9362ded67170732c8a5 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 14:56:49 +0200 Subject: [PATCH 34/35] test(config-conventional): assert positions explicitly Replace toMatchObject with toEqual and pin the start/end values for each conventional-config error fixture. toMatchObject silently ignored the new position fields, leaving them unverified end-to-end; the explicit expectations lock in field-level position behavior across type/subject/header/body/footer rules. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../config-conventional/src/index.test.ts | 126 ++++++++++++++++-- 1 file changed, 114 insertions(+), 12 deletions(-) diff --git a/@commitlint/config-conventional/src/index.test.ts b/@commitlint/config-conventional/src/index.test.ts index 472eef0f24..f793c30326 100644 --- a/@commitlint/config-conventional/src/index.test.ts +++ b/@commitlint/config-conventional/src/index.test.ts @@ -132,21 +132,44 @@ test("type-enum", async () => { const result = await commitLint(messages.invalidTypeEnum); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.typeEnum]); + expect(result.errors).toEqual([ + { + ...errors.typeEnum, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ]); }); test("type-case", async () => { const result = await commitLint(messages.invalidTypeCase); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.typeCase, errors.typeEnum]); + expect(result.errors).toEqual([ + { + ...errors.typeCase, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + { + ...errors.typeEnum, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 4, offset: 3 }, + }, + ]); }); test("type-empty", async () => { const result = await commitLint(messages.invalidTypeEmpty); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.typeEmpty]); + expect(result.errors).toEqual([ + { + ...errors.typeEmpty, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 1, offset: 0 }, + }, + ]); }); test("subject-case", async () => { @@ -156,9 +179,23 @@ test("subject-case", async () => { ), ); - invalidInputs.forEach((result) => { + const headerPrefix = "fix(scope): "; + invalidInputs.forEach((result, i) => { + const input = messages.invalidSubjectCases[i]; + const subject = input.slice(headerPrefix.length); + const offset = headerPrefix.length; expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.subjectCase]); + expect(result.errors).toEqual([ + { + ...errors.subjectCase, + start: { line: 1, column: offset + 1, offset }, + end: { + line: 1, + column: offset + subject.length + 1, + offset: offset + subject.length, + }, + }, + ]); }); }); @@ -166,49 +203,114 @@ test("subject-empty", async () => { const result = await commitLint(messages.invalidSubjectEmpty); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.subjectEmpty, errors.typeEmpty]); + // "fix:" — header length 4; type "fix" at offset 0 length 3. + expect(result.errors).toEqual([ + { + ...errors.subjectEmpty, + start: { line: 1, column: 5, offset: 4 }, + end: { line: 1, column: 5, offset: 4 }, + }, + { + ...errors.typeEmpty, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: 1, offset: 0 }, + }, + ]); }); test("subject-full-stop", async () => { const result = await commitLint(messages.invalidSubjectFullStop); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.subjectFullStop]); + // "fix: some message." — subject "some message." at offset 5, length 13 + // (parser keeps the trailing period in parsed.subject). + expect(result.errors).toEqual([ + { + ...errors.subjectFullStop, + start: { line: 1, column: 6, offset: 5 }, + end: { line: 1, column: 19, offset: 18 }, + }, + ]); }); test("header-max-length", async () => { const result = await commitLint(messages.invalidHeaderMaxLength); + const header = messages.invalidHeaderMaxLength; expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.headerMaxLength]); + expect(result.errors).toEqual([ + { + ...errors.headerMaxLength, + start: { line: 1, column: 1, offset: 0 }, + end: { line: 1, column: header.length + 1, offset: header.length }, + }, + ]); }); test("footer-leading-blank", async () => { const result = await commitLint(messages.warningFooterLeadingBlank); + const message = messages.warningFooterLeadingBlank; + const footerOffset = message.indexOf("BREAKING CHANGE") - 1; expect(result.valid).toBe(true); - expect(result.warnings).toMatchObject([warnings.footerLeadingBlank]); + expect(result.warnings).toEqual([ + { + ...warnings.footerLeadingBlank, + start: { + line: 3, + column: message.split("\n")[2].length + 1, + offset: footerOffset, + }, + end: { + line: 3, + column: message.split("\n")[2].length + 1, + offset: footerOffset, + }, + }, + ]); }); test("footer-max-line-length", async () => { const result = await commitLint(messages.invalidFooterMaxLineLength); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.footerMaxLineLength]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject(errors.footerMaxLineLength); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].end).toBeDefined(); }); test("body-leading-blank", async () => { const result = await commitLint(messages.warningBodyLeadingBlank); + const message = messages.warningBodyLeadingBlank; + const headerLength = message.split("\n")[0].length; expect(result.valid).toBe(true); - expect(result.warnings).toMatchObject([warnings.bodyLeadingBlank]); + expect(result.warnings).toEqual([ + { + ...warnings.bodyLeadingBlank, + start: { + line: 1, + column: headerLength + 1, + offset: headerLength, + }, + end: { + line: 1, + column: headerLength + 1, + offset: headerLength, + }, + }, + ]); }); test("body-max-line-length", async () => { const result = await commitLint(messages.invalidBodyMaxLineLength); expect(result.valid).toBe(false); - expect(result.errors).toMatchObject([errors.bodyMaxLineLength]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject(errors.bodyMaxLineLength); + expect(result.errors[0].start).toBeDefined(); + expect(result.errors[0].end).toBeDefined(); }); test("valid messages", async () => { From 5aed3d329392b163fe74dd86f7940231932b2287 Mon Sep 17 00:00:00 2001 From: escapedcat Date: Mon, 4 May 2026 15:47:21 +0200 Subject: [PATCH 35/35] feat!: enable position indicator by default Reverts the opt-in rollout. The caret renders by default; the field- level rewrite has narrowed the bug surface enough that the interactive-UX win outweighs the small risk of disrupting downstream output parsers, and shipping it on in this release avoids a separate default-flip in a future major. Disable with --no-show-position on the CLI or showPosition: false in formatOptions. Co-Authored-By: Claude Opus 4.7 (1M context) --- @commitlint/cli/src/cli.test.ts | 14 ++++++++------ @commitlint/cli/src/cli.ts | 2 +- @commitlint/format/src/format.test.ts | 4 ++-- @commitlint/format/src/format.ts | 2 +- docs/api/format.md | 2 +- docs/reference/cli.md | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/@commitlint/cli/src/cli.test.ts b/@commitlint/cli/src/cli.test.ts index a682f1280e..583534f92b 100644 --- a/@commitlint/cli/src/cli.test.ts +++ b/@commitlint/cli/src/cli.test.ts @@ -193,19 +193,21 @@ test("should fail for input from stdin with rule from rc", async () => { expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); -test("should hide position indicator caret by default on failure", async () => { +test("should print position indicator caret by default on failure", async () => { const cwd = await gitBootstrap("fixtures/simple"); const result = cli(["--color=false"], { cwd })("foo: bar"); const output = await result; - expect(output.stdout).not.toContain("^"); + expect(output.stdout).toContain("^"); expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); -test("should show position indicator when --show-position is set", async () => { +test("should suppress position indicator when --no-show-position is set", async () => { const cwd = await gitBootstrap("fixtures/simple"); - const result = cli(["--color=false", "--show-position"], { cwd })("foo: bar"); + const result = cli(["--color=false", "--no-show-position"], { cwd })( + "foo: bar", + ); const output = await result; - expect(output.stdout).toContain("^"); + expect(output.stdout).not.toContain("^"); expect(result.exitCode).toBe(ExitCode.CommitlintErrorDefault); }); @@ -667,7 +669,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: false] + --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] diff --git a/@commitlint/cli/src/cli.ts b/@commitlint/cli/src/cli.ts index 605aa374de..4b83404a8d 100644 --- a/@commitlint/cli/src/cli.ts +++ b/@commitlint/cli/src/cli.ts @@ -137,7 +137,7 @@ const cli = yargs(process.argv.slice(2)) }, "show-position": { type: "boolean", - default: false, + default: true, description: "show position of error in output", }, strict: { diff --git a/@commitlint/format/src/format.test.ts b/@commitlint/format/src/format.test.ts index 83e6b397e5..11b83d596b 100644 --- a/@commitlint/format/src/format.test.ts +++ b/@commitlint/format/src/format.test.ts @@ -358,7 +358,7 @@ test("does not show position indicator when showPosition is false", () => { expect(actual).not.toContain("^"); }); -test("hides position indicator when showPosition is not provided (default)", () => { +test("shows position indicator when showPosition is not provided (default)", () => { const actual = format( { results: [ @@ -381,7 +381,7 @@ test("hides position indicator when showPosition is not provided (default)", () }, ); - expect(actual).not.toContain("^"); + expect(actual).toContain("^"); }); test("does not show position indicator when error has no position", () => { diff --git a/@commitlint/format/src/format.ts b/@commitlint/format/src/format.ts index 3cfea8a066..02f4c5dbf8 100644 --- a/@commitlint/format/src/format.ts +++ b/@commitlint/format/src/format.ts @@ -38,7 +38,7 @@ function formatInput( result: FormattableResult & WithInput, options: FormatOptions = {}, ): string[] { - const { color: enabled = true, showPosition = false } = options; + const { color: enabled = true, showPosition = true } = options; const { errors = [], warnings = [], input = "" } = result; if (!input) { diff --git a/docs/api/format.md b/docs/api/format.md index 80b14289b3..c003b76d1e 100644 --- a/docs/api/format.md +++ b/docs/api/format.md @@ -74,7 +74,7 @@ type formatOptions = { /** * Show position indicator (^) for errors in the input line. - * Defaults to `false`. + * Defaults to `true`. **/ showPosition?: boolean; } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e072e1efa4..be70d36594 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -42,7 +42,7 @@ Options: edit=false [string] -V, --verbose enable verbose output for reports without problems [boolean] - --show-position show position of error in output [boolean] [default: false] + --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