Skip to content

feat(cli)!: add --show-position flag to display error location#4629

Open
escapedcat wants to merge 8 commits intomasterfrom
feat/633_show-position-option
Open

feat(cli)!: add --show-position flag to display error location#4629
escapedcat wants to merge 8 commits intomasterfrom
feat/633_show-position-option

Conversation

@escapedcat
Copy link
Member

@escapedcat escapedcat commented Feb 27, 2026

User description

Fixes: #633

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 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 true)
  • Add tests for position indicator in format package
  • Update config-conventional tests to use toMatchObject for backward compatibility

Local test

echo 'foo: not good' | ./@commitlint/cli/cli.js --show-position
⧗   input: foo: not good
           ^
✖   type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]

✖   found 1 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

PR Type

Enhancement


Description

  • Add --show-position CLI flag to display error position indicators

  • Implement position tracking for lint rule outcomes with start coordinates

  • Add getRulePosition() helper to calculate error positions for various rules

  • Display position indicator (~~~) under commit input when flag is enabled

  • Update test assertions to use toMatchObject for backward compatibility


Diagram Walkthrough

flowchart LR
  CLI["CLI Flag<br/>--show-position"]
  LINT["Lint Engine<br/>getRulePosition()"]
  FORMAT["Format Engine<br/>getPositionIndicator()"]
  OUTPUT["Output<br/>Position Indicator"]
  CLI --> LINT
  LINT --> FORMAT
  FORMAT --> OUTPUT
Loading

File Walkthrough

Relevant files
Enhancement
6 files
cli.ts
Add show-position CLI flag definition                                       
+5/-0     
types.ts
Add show-position to CliFlags interface                                   
+1/-0     
lint.ts
Implement getRulePosition helper and position tracking     
+141/-3 
format.ts
Add position indicator rendering logic                                     
+72/-4   
lint.ts
Add position fields to LintRuleOutcome interface                 
+4/-0     
format.ts
Add position fields and showPosition option to types         
+3/-0     
Tests
3 files
cli.test.ts
Update help text to include show-position flag                     
+1/-0     
format.test.ts
Add comprehensive position indicator tests                             
+163/-0 
index.test.ts
Update assertions to use toMatchObject                                     
+11/-11 
Documentation
1 files
format.md
Document showPosition option in format API                             
+5/-0     

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
@qodo-code-review
Copy link

qodo-code-review bot commented Feb 27, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🟡
🎫 #633
🟢 Extend (“pimp”) the rules/core API as needed so rule violations can provide
location/position information that the formatter/CLI can render.
Make the error position in the commit message more visible in the output by adding a
visual indicator (e.g., a squiggly/~~~ underline) pointing to the problematic area.
Confirm the UX matches the ticket’s intended default behavior (PR makes it opt-in via
--show-position) and verify output correctness across real commit messages/rules beyond
the covered tests.
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Edge case handling: The new getPositionIndicator() logic relies on hard-coded padding and \n\n splitting and
may mis-handle inputs with \r\n, multi-line bodies/footers, or differing rendered prefix
lengths, potentially producing missing/misaligned indicators rather than a clear graceful
fallback.

Referred Code
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");


 ... (clipped 32 lines)

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@codesandbox-ci
Copy link

codesandbox-ci bot commented Feb 27, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 27, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Ensure correct position for commit type
Suggestion Impact:Updated the type rule position logic to require raw.startsWith(parsed.type) and set offset to 0, preventing incorrect matches later in the commit message.

code diff:

@@ -45,8 +45,8 @@
 		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: {

To ensure the correct position of the commit type is found, replace
raw.indexOf(parsed.type) with a check using raw.startsWith(parsed.type), as the
type must be at the beginning of the commit message.

@commitlint/lint/src/lint.ts [42-58]

 		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;
+			if (!raw.startsWith(parsed.type)) return undefined;
+			const offset = 0;
 			return {
 				start: { line: 1, column: offset + 1, offset },
 				end: {
 					line: 1,
 					column: offset + parsed.type.length + 1,
 					offset: offset + parsed.type.length,
 				},
 			};
 		}

[Suggestion processed]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a bug in the new position-finding logic where indexOf could match the wrong part of the commit message, and the proposed fix using startsWith is more robust and accurate.

Medium
Fix incorrect position indicator length
Suggestion Impact:Updated body/footer line-length calculation to use only the first line of body/footer text and adjusted tilde length clamping to line bounds, addressing multi-line and off-by-one highlighting issues (with a slight variation allowing start.column <= lineLength + 1). Also expanded position indicator input to include warnings.

code diff:

@@ -91,16 +91,15 @@
 		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 @@
 		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);

Fix the calculation for the position indicator's length for body and footer
errors to correctly handle multi-line content and prevent off-by-one errors in
highlighting.

@commitlint/format/src/format.ts [90-121]

 	} 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;
+		const bodyText = input.slice(bodyLineStart);
+		const firstBodyLine = bodyText.split("\n")[0];
+		const lineLength = firstBodyLine.length;
 
 		if (start.column <= lineLength) {
 			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);
 		}
 	} 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;
+		const footerText = input.slice(footerLineStart);
+		const firstFooterLine = footerText.split("\n")[0];
+		const lineLength = firstFooterLine.length;
 
 		if (start.column <= lineLength) {
 			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);
 		}
 	}

[Suggestion processed]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies and fixes a bug in the position indicator logic for multi-line body and footer content, which would otherwise lead to incorrect error highlighting.

Medium
High-level
Rule-specific error location is imprecise

The centralized getRulePosition helper function is imprecise for some rules,
highlighting entire sections like the body. It would be more accurate and
scalable if each rule reported its own specific error coordinates.

Examples:

@commitlint/lint/src/lint.ts [24-147]
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;

 ... (clipped 114 lines)

Solution Walkthrough:

Before:

// In @commitlint/lint/src/lint.ts
async function lint(message, rules, opts) {
  // ...
  const parsed = await parse(message, opts.parserOpts);
  // ...
  const pendingResults = activeRules.map(async ([name, config]) => {
    const rule = allRules.get(name);
    const [valid, message] = await rule(parsed, when, value);

    const position = !valid ? getRulePosition(name, parsed) : undefined;

    return { level, valid, name, message, ...position };
  });
  // ...
}

function getRulePosition(ruleName, parsed) {
  // switch/case on ruleName to guess position
  // For body rules, highlights the entire body
}

After:

// In @commitlint/lint/src/lint.ts
async function lint(message, rules, opts) {
  // ...
  const parsed = await parse(message, opts.parserOpts);
  // ...
  const pendingResults = activeRules.map(async ([name, config]) => {
    const rule = allRules.get(name);
    // Rule returns {valid, message, start?, end?}
    const result = await rule(parsed, when, value);

    return { level, name, ...result };
  });
  // ...
}

// Each rule file would be updated, e.g. body-max-line-length.ts
const bodyMaxLineLength = (parsed, when, value) => {
  // ... logic to find the specific line that is too long
  const errorLine = ...;
  const position = { start: ..., end: ... }; // a precise position
  return { valid: false, message: "...", ...position };
}
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a design limitation in the new getRulePosition function, where error highlighting can be imprecise for rules targeting large text blocks, which impacts the core quality of the new feature.

Medium
General
Generalize line handling
Suggestion Impact:Updated body/footer position handling to derive the first line length by slicing and splitting on "\n" rather than manual index calculations, and adjusted column bounds/tilde length calculations accordingly (though it did not fully generalize to arbitrary line numbers).

code diff:

@@ -91,16 +91,15 @@
 		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 @@
 		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);
 		}

Refactor getPositionIndicator to handle any line number dynamically by splitting
the input into lines, which reduces code duplication and improves
maintainability.

@commitlint/format/src/format.ts [86-121]

-if (start.line === 1) {
-  // handle header
-  ...
-} else if (start.line === 2) {
-  // handle body
-  ...
-} else if (start.line === 3) {
-  // handle footer
-  ...
-}
+const lines = input.replace(/\r\n/g, "\n").split("\n");
+const lineText = lines[start.line - 1];
+if (!lineText) return undefined;
+const spacesBefore = Math.max(0, start.column - 1);
+const tildeLength = Math.max(1, Math.min(end.column, lineText.length + 1) - start.column);
+const prefix = `${enabled ? pc.gray(sign) : sign}   input: `;
+const padding = " ".repeat(prefix.length);
+indicator = padding + " ".repeat(spacesBefore) + tilde.repeat(tildeLength);

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 7

__

Why: This suggestion provides a significant refactoring that simplifies the getPositionIndicator function, removes duplicated code, and makes the logic more robust and extensible for handling errors on any line.

Medium
  • Update

This comment was marked as resolved.

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.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…ndling

- 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
- Fix typo: somehting -> something in test file
- 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
- 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
@escapedcat
Copy link
Member Author

@knocte wdyt?

@escapedcat escapedcat requested a review from JounQin February 28, 2026 10:55
@knocte
Copy link
Contributor

knocte commented Mar 1, 2026

@knocte wdyt?

I love this! But IMO:

  • Why add a flag and not just enable this by default?
  • Why use multiple ~ chars instead of just one single ^ like most errors use?

@escapedcat
Copy link
Member Author

* Why add a flag and not just enable this by default?

I'm worried this would be a breaking change and mess up whatever people do with current output format

* Why use multiple `~` chars instead of just one single `^` like most errors use?

As it was described in the issue and also as I mostly know it from other linters I guess

@knocte
Copy link
Contributor

knocte commented Mar 1, 2026

I'm worried this would be a breaking change and mess up whatever people do with current output format

I figured, but think about it: by being a flag this feature would be not discoverable at all, most people will not use it because they don't know about it. As a consequence of this, at some point you will think of making it the default, and then the breaking change will happen. Why not make the breaking change already? Just do a higher version in the bump. And let people file bugs, we'll fix them?

As it was described in the issue

In the issue they use an example of something that has known length (the string "thisDoesNotExist") and so the compiler prints as many ~ chars as the length of the string. But does this really apply to commitlint? Do all rules' violations have to do with a specific string that has certain lenght? My guess is no, for example: if title of commit message is too long (e.g. 65 chars, so exceeds the configured limit 50), is commitlint going to print as many as 15 ~ chars? Also, the longer the title is, the more likely it is that horizontal wrapping will make those chars not really align with the previous string. On the other hand, if you use just one single ^ char here, you could just point to the 50th char, as meaning: "that's the max length for the title".

and also as I mostly know it from other linters I guess

Funny you say that because I spotted some compiler errors from typescript, pasted by people into issues, and all I saw is just a single ^ char myself, maybe the use of ~ is only in special cases.

@escapedcat escapedcat changed the title feat(cli): add --show-position flag to display error location feat(cli)!: add --show-position flag to display error location Mar 1, 2026
@escapedcat escapedcat force-pushed the feat/633_show-position-option branch from 7729dae to 1b2ab42 Compare March 1, 2026 16:33
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +154 to +188
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) {
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;
return {
start: { line: 3, column: 1, offset: footerStartOffset },
end: {
line: 3,
column: parsed.footer.length + 1,
offset: footerStartOffset + parsed.footer.length,
},
};
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The body and footer line numbers are hardcoded as line: 2 and line: 3 respectively. This is incorrect for commit messages with multi-line bodies. For example, a commit with a body spanning 5 lines would have a footer starting at line 7, not line 3. The actual line number should be computed dynamically by counting the newlines before the footer start offset in the raw string.

Copilot uses AI. Check for mistakes.
@knocte
Copy link
Contributor

knocte commented Mar 2, 2026

Wow, copilot feedback is really good

@escapedcat escapedcat force-pushed the feat/633_show-position-option branch 3 times, most recently from bc208cc to 13def8c Compare March 2, 2026 11:37
- 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.
@escapedcat escapedcat force-pushed the feat/633_show-position-option branch from 13def8c to 720e751 Compare March 2, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

Make error (position) more visible in output

3 participants