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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/vale-raw-scope-line-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@taskless/cli": patch
---

Fixed `check --json` reporting a `raw`-scope Vale finding's `range.start.line`
one line earlier than the flagged text (#297). A `raw` pattern is
conventionally anchored with a leading `\n` so it can require "start of line"
against the unparsed document; that `\n` is part of Vale's reported match, and
Vale attributes `Line` to the newline ending the previous line rather than to
the line the flagged text is actually on. The mapper now counts a match's
leading newlines and adds them back before converting to the 0-indexed
`CheckResult.range` every source uses.

`default`-scope findings were not affected: Vale already reports the correct
1-based line for them, and `range.start.line` is 0-indexed by design (every
source in `CheckResult.range` is — `format.ts` adds 1 back when it displays,
and #297's "off by one" for default-scope rules was this documented
convention compared against a 1-based file line, not a bug).
89 changes: 79 additions & 10 deletions packages/cli/src/rules/vale/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,29 +143,98 @@ function toFix(finding: ValeFinding): string | undefined {
return typeof replacement === "string" ? replacement : undefined;
}

/**
* Vale's `raw`-scope patterns are conventionally anchored with a leading
* `\n` (matching against the unparsed document lets a pattern require "start
* of line" this way, since `raw` has no notion of line boundaries otherwise).
* That leading `\n` is *part of the match*, so `Match` starts with it, and
* Vale attributes `Line` to where the match itself starts — the newline that
* *ends* the previous line — rather than to the line the flagged text is
* actually on.
*
* Measured against the real binary: a `raw` rule matching
* `\n**The base is a promise...` on a line whose true (1-based) number is 13
* is reported by Vale as `Line: 12`, one line early, while a `default`-scope
* rule matching the same document reports the correct 1-based line with no
* such offset. Counting the match's leading newlines and adding them back
* corrects this for any number of leading newlines, not just one, and is a
* no-op for every scope that does not open a match on `\n`.
*/
function leadingNewlines(text: string): number {
let count = 0;
while (text[count] === "\n") count++;
return count;
}

/**
* `Span` for a leading-newline `raw` match is measured in the *attributed*
* (wrong) line's coordinate space, not the corrected one, so it cannot be
* reused verbatim once {@link leadingNewlines} moves the line forward.
*
* Measured against the real binary: for a match opening with one `\n`
* preceded by a 70-character line, Vale reports `Span: [71, 129]` — 71 is
* that preceding line's length plus one, and 129 is 71 plus the *whole*
* match length (59) minus one. Vale is not tracking per-line columns here at
* all; it is counting characters from the start of the line it (wrongly)
* attributed the match to, straight through the leading `\n` and into the
* flagged text, however many characters that takes. A blank preceding line
* (length 0) makes `Span` start at 1, which happens to equal the true
* column — that coincidence is what made the original fixture look correct.
*
* The character immediately after a `\n` is always column 1 of the next
* line, independent of how long the previous line was or how many leading
* newlines the match opened with (each one just steps down one more line).
* So once a match has any leading newlines, the true start column is always
* the first column, and the true end column is however long the match is
* *after* stripping those newlines — confirmed against the real binary for
* both one and two leading newlines. `Span` is only trustworthy as-is when
* there is no leading newline to correct for.
*/
function rawScopeColumns(
span: [number, number],
match: string,
newlines: number
): [number, number] {
if (newlines === 0) {
const [spanStart, spanEnd] = span;
return [Math.max(0, spanStart - 1), Math.max(0, spanEnd - 1)];
}
const strippedLength = match.length - newlines;
return [0, Math.max(0, strippedLength - 1)];
}

/**
* Map one Vale finding to the scanner-agnostic {@link CheckResult}.
*
* `range` collapses to a single line: Vale reports `Line` plus a `Span` of
* columns within it, and has no concept of a finding that crosses lines, so
* start and end share the line number.
*
* Both are converted down by one. `CheckResult.range` is 0-indexed — ast-grep's
* native range is passed straight through by `toCheckResult`, the runtime
* harness converts its 1-based `Finding` down the same way, and `format.ts` adds
* 1 back for every source when it displays. Vale's `Line` and `Span` are both
* 1-based, so emitting them verbatim would report every finding one line and one
* column further into the file than it is. Clamped at 0 because a 0 from Vale
* The line is corrected for a `raw`-scope match's leading newlines (see
* {@link leadingNewlines}) and then converted down by one; the columns get
* their own correction (see {@link rawScopeColumns}) because `Span` is
* measured against the line Vale attributed the match to, which is no longer
* the line this range reports once the line correction moves it. Both
* corrections are no-ops when the match has no leading newline.
* `CheckResult.range` is 0-indexed — ast-grep's native range is passed
* straight through by `toCheckResult`, the runtime harness converts its
* 1-based `Finding` down the same way, and `format.ts` adds 1 back for every
* source when it displays. Vale's `Line` and `Span` are both 1-based, so
* emitting them verbatim would report every finding one line and one column
* further into the file than it is. Clamped at 0 because a 0 from Vale
* (unset, rather than a real position) must not become -1.
*/
export function toValeCheckResult(
file: string,
finding: ValeFinding
): CheckResult {
const [spanStart, spanEnd] = finding.Span;
const line = Math.max(0, finding.Line - 1);
const startColumn = Math.max(0, spanStart - 1);
const endColumn = Math.max(0, spanEnd - 1);
const newlines = leadingNewlines(finding.Match);
const line = Math.max(0, finding.Line + newlines - 1);
const [startColumn, endColumn] = rawScopeColumns(
finding.Span,
finding.Match,
newlines
);
return {
source: "vale",
ruleId: stripRulesPrefix(finding.Check),
Expand Down
Loading
Loading