Skip to content

fix(cli): correct raw-scope Vale line numbers - #314

Open
thecodedrift wants to merge 1 commit into
mainfrom
fix/vale-finding-line-numbers
Open

fix(cli): correct raw-scope Vale line numbers#314
thecodedrift wants to merge 1 commit into
mainfrom
fix/vale-finding-line-numbers

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Summary

check --json reported a raw-scope Vale finding's line one line earlier than the flagged text (#297). raw patterns are conventionally anchored with a leading \n so they 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.

toValeCheckResult now counts a match's leading newlines (via Match, already on the payload) and adds them back before the existing 1-based-to-0-based conversion.

The other half of #297 was not a bug

The issue also reported default-scope findings landing "one line early." That is CheckResult.range working as documented: it is 0-indexed for every source (ast-grep's native range passes straight through, format.ts adds 1 back for every source when it displays), and Vale itself reports the correct 1-based line for a default-scope match. The issue's table compared that 0-based JSON payload against a 1-based file line number.

Established by:

  • reading map.ts's existing comment and vale-map.test.ts's existing worked example, which already encodes Line: 3range.start.line: 2
  • confirming format.ts:12 adds + 1 back when it displays range.start.line
  • confirming check.ts emits results verbatim under --json with no re-conversion
  • running the real Vale binary against a fixture, which reported the true 1-based line for a default-scope match with no offset of its own

Measured

Repro with the built CLI on a fixture with front matter (doc lines 1-based: to be honest on line 7, raw target on line 9):

rule before (range.start.line, 0-based) after true (1-based)
default-scope 6 6 (unchanged) 7
raw-scope 7 8 9

Before the fix, raw was one line short of the 0-based value that matches the default-scope contract (6 vs 8 would be the base+2 shift the issue measured in 1-based terms across scopes; here shown as the 0-based JSON values the built CLI actually emits).

Tests

Added to packages/cli/test/vale-map.test.ts:

  • synthetic toValeCheckResult cases for 1 leading newline, 2 leading newlines, no leading newline (default-scope), and an embedded (non-leading) newline
  • a real-Vale-binary suite (toValeCheckResult against the real Vale binary) with a fixture that has YAML front matter, one default-scope rule, and one raw-scope rule, asserting range.start.line for both

Mutation-tested: reverting the leading-newline correction failed exactly the 3 tests meant to catch it (2 synthetic + the real-binary raw test) while both default-scope tests stayed green; substituting a naive "count all newlines" implementation was caught by the "does not count a newline appearing after the match's start" test and nothing else.

Verification

  • pnpm typecheck — pass
  • pnpm test — 1351/1351 pass
  • pnpm lint — pass (check:style reports "No issues found")
  • Reproduced the issue end-to-end with the built CLI (check doc.md --json) both before and after the fix, shown above

No changeset needed for the documentation half since no behavior changed there; one changeset added (patch, pre-1.0 per repo convention) for the raw-scope line fix.

Fixes #297

…0-based contract

Vale's `raw` patterns are conventionally anchored with a leading `\n` so
they can require "start of line" against the unparsed document. That
`\n` is part of the reported match, and Vale attributes `Line` to the
newline ending the previous line rather than to the line the flagged
text is actually on, so a raw-scope finding always landed one line
early on top of the mapper's existing 1-based-to-0-based conversion.

`toValeCheckResult` now counts a match's leading newlines and adds
them back before converting to the 0-indexed `CheckResult.range`.

The other half of #297 — default-scope findings reading "one line
early" — is not a bug: `CheckResult.range` is 0-indexed by design for
every source (ast-grep's native range passes straight through, and
`format.ts` adds 1 back for every source when it displays). Vale
itself reports the correct 1-based line for a default-scope match;
the issue's table compared that 0-based JSON output against a 1-based
file line number. Confirmed by reading map.ts's existing comment and
tests, and by running the real Vale binary against a fixture with
front matter.

Added tests pinning both: a default-scope rule on a fixture with
front matter as a regression guard for the working-as-designed half,
and both synthetic and real-Vale-binary raw-scope tests for the fix.
Mutation-tested by reverting the leading-newline correction and by
substituting a broken newline-counting implementation; both mutations
were caught by the new tests and only those tests.
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 6m 27s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json held no review threads, reviews, or resolved comments — only the @claude /review trigger comment — so this is the first review of this PR; everything below is assessed against the whole diff.

Todo list

  • Read .prior-review.json (no prior findings to classify)
  • Gather context (PR body, changed files)
  • Read the diff (gh pr diff) and full contents of changed files
  • Review packages/cli/src/rules/vale/map.ts for correctness
  • Review packages/cli/test/vale-map.test.ts for adequacy
  • Review changeset file
  • Post inline comments for issues found
  • Post final summary

Overall assessment

The core diagnosis and fix are sound: leadingNewlines counts a raw-scope match's leading \ns and adds them back before the existing 1-based→0-based conversion, which is the right root-cause fix (it generalizes to any number of leading newlines rather than hard-coding +1), and it's well covered by both synthetic unit tests and a real-Vale-binary regression suite for the primary (single-newline) case. The write-up correctly separates the real bug (raw-scope) from the non-bug (default-scope range being 0-indexed by design, confirmed by format.ts's +1 on display) — I re-derived the same conclusion independently from map.ts's docstring, toValeCheckResults' single call site, and the format.ts display path.

Two things worth a second look before merge, posted as inline comments:

  1. packages/cli/src/rules/vale/map.ts (line ~191) — the fix corrects line for a raw-scope leading-newline match but leaves startColumn/endColumn (from finding.Span) untouched. If Vale computes Span relative to the same (wrong) line it attributes to Line, a raw match anchored on \n right after a non-blank line could still report a wrong column, even though Line is now correct. The PR's fixture happens to have a blank line before the raw target, which may be masking this. I couldn't run the real binary in this sandbox to confirm either way (no node_modules/Vale binary installed), so this is flagged as a plausible gap for the author to check, not a confirmed bug.
  2. packages/cli/test/vale-map.test.ts (lines ~122–131) — the 2-leading-newline case is only verified synthetically, not against the real Vale binary the way the 1-leading-newline case is. It's cheap insurance to extend the real-binary suite with a \n\n-anchored raw rule to confirm Vale's line-attribution model actually generalizes the way the synthetic test assumes.

Everything else — import ordering/grouping (matches .conventions/STYLEGUIDE-CODE.md), the changeset's patch bump (correct for pre-1.0 per repo convention), test cleanup (afterEach + rmSync on temp workspaces, mirroring the existing pattern in vale-run.test.ts), and the docstring updates — looked correct and proportionate to the fix. No CLAUDE.md violations found in the diff.

Comment on lines 190 to 192
const [spanStart, spanEnd] = finding.Span;
const line = Math.max(0, finding.Line - 1);
const line = Math.max(0, finding.Line + leadingNewlines(finding.Match) - 1);
const startColumn = Math.max(0, spanStart - 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] line is corrected for a raw-scope match's leading newlines, but startColumn/endColumn are still derived from finding.Span unchanged.

Vale computes Span relative to whatever line it attributes the match to (per the docstring above, it "has no concept of a finding that crosses lines"). For a raw match that opens with \n, Vale attributes the match to the line before the flagged text — so Span is presumably measured as an offset from the start of that (wrong) line, through the leading \n, into the actual flagged text on the next line.

That happens to come out numerically right when the preceding line is empty (0 characters of offset to absorb, as in this PR's fixture — a blank line before the raw target). But if a raw pattern is anchored on \n immediately after a non-blank line (e.g. \n#\s*Heading matching right after real prose, rather than after a blank separator line), the previous line's own character count would still be baked into Span, producing a startColumn/endColumn that doesn't correspond to any real position on the now-corrected line.

Since I can't run the real Vale binary in this environment to confirm the exact column semantics, this is a plausible gap rather than a confirmed one — worth a quick check (e.g. a fixture where the raw match's leading \n follows a non-empty line) to see whether column also needs the same kind of correction, or whether Vale already reports it relative to the matched text regardless of Line.

Comment on lines +122 to +131
const result = toValeCheckResult("docs/a.md", {
...example,
Line: 10,
Match: "\n\nSome flagged text",
});
expect(result.range.start.line).toBe(11);
});

it("leaves a default-scope match (no leading newline) unaffected", () => {
const result = toValeCheckResult("docs/a.md", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[New] The "advances the line by the count of leading newlines, not just one" case (2 leading newlines) is only exercised synthetically, never against the real Vale binary — unlike the single-leading-newline case, which the real-binary suite further down pins directly (toValeCheckResult against the real Vale binary).

This synthetic test only confirms leadingNewlines/toValeCheckResult do the arithmetic they're written to do; it doesn't confirm that Vale actually attributes Line the same way (one line "too early" per leading \n) when a raw match opens with two consecutive newlines rather than one. If Vale's line-attribution for that case differs even slightly (e.g. if it always lands on the line right before the match regardless of how many \ns are consumed getting there), this test would still pass while the real fix silently mis-corrects. Given the PR already has a real-Vale-binary suite and fixture pattern in place, it would be low-cost to add one raw-scope rule anchored on \n\n (e.g. "blank line required before this heading") to close the gap the way the single-newline case is already closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check --json reports Vale findings 1 line early, or 2 lines early for raw-scope rules

1 participant