-
Notifications
You must be signed in to change notification settings - Fork 0
fix(cli): correct raw-scope Vale line numbers #314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,11 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { spawnSync } from "node:child_process"; | ||
| import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| import { afterEach, describe, expect, it } from "vitest"; | ||
|
|
||
| import { findValeBinary } from "../src/rules/vale/binary"; | ||
| import { | ||
| asValeConfigError, | ||
| normalizeSeverity, | ||
|
|
@@ -93,6 +99,56 @@ describe("toValeCheckResult", () => { | |
| expect(result.range.start.line).toBe(result.range.end.line); | ||
| }); | ||
|
|
||
| describe("raw-scope leading newlines", () => { | ||
| // `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 match, and Vale attributes `Line` to where the match | ||
| // itself starts (the newline ending the previous line) rather than to | ||
| // the line the flagged text is actually on. See `leadingNewlines` in | ||
| // map.ts. | ||
|
|
||
| it("advances the line by one for a single leading newline", () => { | ||
| // Vale's `Line: 12` here names the blank line before the flagged text, | ||
| // which is truly on line 13 (1-based) / 12 (0-based). | ||
| const result = toValeCheckResult("docs/a.md", { | ||
| ...example, | ||
| Line: 12, | ||
| Match: "\n**The base is a promise about the build.**", | ||
| }); | ||
| expect(result.range.start.line).toBe(12); | ||
| }); | ||
|
|
||
| it("advances the line by the count of leading newlines, not just one", () => { | ||
| 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", { | ||
|
Comment on lines
+122
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( This synthetic test only confirms |
||
| ...example, | ||
| Line: 9, | ||
| Match: "To be honest", | ||
| }); | ||
| expect(result.range.start.line).toBe(8); | ||
| }); | ||
|
|
||
| it("does not count a newline appearing after the match's start", () => { | ||
| // Only *leading* newlines are the artifact of the anchoring pattern; an | ||
| // embedded one is part of the matched content, not a start-of-match | ||
| // marker, and must not shift the line. | ||
| const result = toValeCheckResult("docs/a.md", { | ||
| ...example, | ||
| Line: 9, | ||
| Match: "To be honest\nabout it", | ||
| }); | ||
| expect(result.range.start.line).toBe(8); | ||
| }); | ||
| }); | ||
|
|
||
| it("joins Description and Link into note, and omits it when both are empty", () => { | ||
| expect( | ||
| toValeCheckResult("a.md", { ...example, Description: "Say it plainly." }) | ||
|
|
@@ -147,6 +203,105 @@ describe("toValeCheckResult", () => { | |
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * These run the real Vale binary rather than fabricating `ValeFinding` | ||
| * objects, because the whole bug (#297) was Vale's own reported `Line` | ||
| * disagreeing with the true line, and a hand-built fixture would only prove | ||
| * that `toValeCheckResult` does what we assume Vale does, not what it | ||
| * actually does. Vale ships as an `optionalDependency` for the host | ||
| * platform, so it is present in CI and absent only on an unsupported arch. | ||
| */ | ||
| const valeAvailable = findValeBinary().path !== undefined; | ||
| const withVale = valeAvailable ? describe : describe.skip; | ||
|
|
||
| withVale("toValeCheckResult against the real Vale binary", () => { | ||
| const workspaces: string[] = []; | ||
|
|
||
| afterEach(() => { | ||
| for (const workspace of workspaces.splice(0)) { | ||
| rmSync(workspace, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| /** | ||
| * A document with YAML front matter (`---` on lines 1 and 3 here), since | ||
| * the issue's repro documents all carried front matter and the bug was | ||
| * measured against them. | ||
| * | ||
| * Line numbers (1-based), annotated because the test asserts against them: | ||
| * 1: --- | ||
| * 2: title: Test | ||
| * 3: --- | ||
| * 4: (blank) | ||
| * 5: Intro paragraph. | ||
| * 6: (blank) | ||
| * 7: To be honest, this is the default-scope target. | ||
| * 8: (blank) | ||
| * 9: **The base is a promise about the build.** Raw-scope target. | ||
| * 10: (blank) | ||
| */ | ||
| const fixture = [ | ||
| "---", | ||
| "title: Test", | ||
| "---", | ||
| "", | ||
| "Intro paragraph.", | ||
| "", | ||
| "To be honest, this is the default-scope target.", | ||
| "", | ||
| "**The base is a promise about the build.** Raw-scope target.", | ||
| "", | ||
| ].join("\n"); | ||
|
|
||
| function runValeDirect(): ValeFinding[] { | ||
| const cwd = mkdtempSync(join(tmpdir(), "vale-map-")); | ||
| workspaces.push(cwd); | ||
| mkdirSync(join(cwd, "styles", "Test"), { recursive: true }); | ||
| writeFileSync( | ||
| join(cwd, ".vale.ini"), | ||
| "StylesPath = styles\nMinAlertLevel = suggestion\n\n[*.md]\nBasedOnStyles = Test\n" | ||
| ); | ||
| writeFileSync( | ||
| join(cwd, "styles", "Test", "DefaultScope.yml"), | ||
| "extends: existence\nmessage: \"Avoid '%s'\"\nlevel: warning\nignorecase: true\ntokens:\n - 'to be honest'\n" | ||
| ); | ||
| writeFileSync( | ||
| join(cwd, "styles", "Test", "RawScope.yml"), | ||
| "extends: existence\nmessage: \"Raw hit: '%s'\"\nlevel: warning\nscope: raw\nraw:\n - '\\n\\*\\*The base is a promise.*'\n" | ||
| ); | ||
| writeFileSync(join(cwd, "doc.md"), fixture); | ||
|
|
||
| const { path: binary } = findValeBinary(); | ||
| const result = spawnSync( | ||
| binary as string, | ||
| ["--config=.vale.ini", "--output=JSON", "doc.md"], | ||
| { cwd, encoding: "utf8" } | ||
| ); | ||
| const output = JSON.parse(result.stdout) as Record<string, ValeFinding[]>; | ||
| return output["doc.md"] ?? []; | ||
| } | ||
|
|
||
| it("reports the true line for a default-scope rule", () => { | ||
| const findings = runValeDirect(); | ||
| const finding = findings.find((f) => f.Check === "Test.DefaultScope"); | ||
| expect(finding).toBeDefined(); | ||
| // True (1-based) line is 7; `CheckResult.range` is 0-indexed. | ||
| const result = toValeCheckResult("doc.md", finding!); | ||
| expect(result.range.start.line).toBe(6); | ||
| }); | ||
|
|
||
| it("reports the true line for a raw-scope rule, correcting the leading-newline offset", () => { | ||
| const findings = runValeDirect(); | ||
| const finding = findings.find((f) => f.Check === "Test.RawScope"); | ||
| expect(finding).toBeDefined(); | ||
| // True (1-based) line is 9; `CheckResult.range` is 0-indexed. Vale itself | ||
| // reports `Line: 8` here (the blank line before), which this test would | ||
| // catch a regression back to if `leadingNewlines` were removed. | ||
| const result = toValeCheckResult("doc.md", finding!); | ||
| expect(result.range.start.line).toBe(8); | ||
| }); | ||
| }); | ||
|
|
||
| describe("toValeCheckResults", () => { | ||
| it("pushes the file key down onto each finding", () => { | ||
| const results = toValeCheckResults({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[New]
lineis corrected for a raw-scope match's leading newlines, butstartColumn/endColumnare still derived fromfinding.Spanunchanged.Vale computes
Spanrelative to whatever line it attributes the match to (per the docstring above, it "has no concept of a finding that crosses lines"). For arawmatch that opens with\n, Vale attributes the match to the line before the flagged text — soSpanis 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
rawpattern is anchored on\nimmediately after a non-blank line (e.g.\n#\s*Headingmatching right after real prose, rather than after a blank separator line), the previous line's own character count would still be baked intoSpan, producing astartColumn/endColumnthat 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
\nfollows 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 ofLine.