From 3d1bad9c88e25038abd5ff39a37fae9854813160 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 16 Aug 2026 05:39:14 +0000 Subject: [PATCH] fix(coding-agent): map biome 2.x json reporter schema BiomeClient#parseJsonOutput expected a stale --reporter=json shape (location.path.file, byte-offset span, description), so the shape guard dropped every diagnostic against Biome 2.x output and lint() returned [] with no warning. Parse the current schema instead: string location.path, 1-indexed location.start/end {line,column}, and message. Drop the now-dead offsetsToPositions helper and warn when a non-empty diagnostics array has no recognizable location, so future schema drift surfaces instead of silently reporting "no lint issues". Fixes #8694 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/lsp/clients/biome-client.ts | 140 ++++++------------ .../coding-agent/test/biome-client.test.ts | 41 +++++ 3 files changed, 92 insertions(+), 93 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f61f4fc3d..48365c8df 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,10 @@ - Kept automatic model selection on paid `xai/grok-4.5` when only `XAI_API_KEY` is set, instead of preferring SuperGrok `xai-oauth/grok-4.5`. Explicit `xai-oauth/grok-4.5` still works with that paid key. - Stopped sending presence/frequency penalties and stop sequences to xAI reasoning models such as `grok-4.5`, which reject them. +### Fixed + +- Fixed the Biome linter client silently dropping every diagnostic because `#parseJsonOutput` expected an outdated `--reporter=json` schema (`location.path.file`, byte-offset `span`, `description`); it now reads Biome 2.x's string `location.path`, 1-indexed `location.start`/`location.end`, and `message`, and warns when a non-empty diagnostics array has no recognizable location ([#8694](https://github.com/can1357/oh-my-pi/issues/8694)). + ## [17.3.4] - 2026-08-14 ### Changed diff --git a/packages/coding-agent/src/lsp/clients/biome-client.ts b/packages/coding-agent/src/lsp/clients/biome-client.ts index 2e1cfbbf6..9ee45fdc5 100644 --- a/packages/coding-agent/src/lsp/clients/biome-client.ts +++ b/packages/coding-agent/src/lsp/clients/biome-client.ts @@ -14,14 +14,21 @@ interface BiomeJsonOutput { diagnostics: BiomeDiagnostic[]; } +/** + * A single diagnostic from Biome's `--reporter=json` output (Biome 2.x). + * + * Positions are 1-indexed `{ line, column }` pairs; the path is a plain string + * relative to the CLI's cwd. Older releases used byte-offset `span`s, which + * this client no longer parses. + */ interface BiomeDiagnostic { category: string; // e.g., "lint/correctness/noUnusedVariables" - severity: "error" | "warning" | "info" | "hint"; - description: string; + severity: string; // "error" | "warning" | "info" | "hint" + message: string; location?: { - path?: { file: string }; - span?: [number, number]; // [startOffset, endOffset] in bytes - sourceCode?: string; + path?: string; + start?: { line: number; column: number }; + end?: { line: number; column: number }; }; } @@ -29,43 +36,6 @@ interface BiomeDiagnostic { // Helpers // ============================================================================= -/** - * Convert byte offsets to line:column positions in a single pass over the source. - */ -function offsetsToPositions(source: string, offsets: number[]): Map { - const sorted = [...new Set(offsets)].sort((a, b) => a - b); - const result = new Map(); - let line = 1; - let column = 1; - let byteIndex = 0; - let next = 0; - - for (const ch of source) { - if (next >= sorted.length) break; - const cp = ch.codePointAt(0) as number; - const byteLen = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; - while (next < sorted.length && byteIndex + byteLen > sorted[next]) { - result.set(sorted[next], { line, column }); - next++; - } - if (ch === "\n") { - line++; - column = 1; - } else { - column++; - } - byteIndex += byteLen; - } - - // Offsets at or past end-of-file map to the final position. - while (next < sorted.length) { - result.set(sorted[next], { line, column }); - next++; - } - - return result; -} - /** * Parse Biome severity to LSP DiagnosticSeverity. */ @@ -176,8 +146,6 @@ export class BiomeClient implements LinterClient { * Parse Biome's JSON output into LSP Diagnostics. */ #parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - let parsed: BiomeJsonOutput; try { parsed = JSON.parse(jsonOutput); @@ -186,61 +154,36 @@ export class BiomeClient implements LinterClient { cwd: this.cwd, file: targetFile, }); - return diagnostics; + return []; } + const emitted = parsed.diagnostics ?? []; const target = path.resolve(targetFile); - const relevant: BiomeDiagnostic[] = []; - // Batch all span offsets per source text so each source is scanned once - // instead of twice per diagnostic. - const offsetsBySource = new Map(); - for (const diag of parsed.diagnostics ?? []) { + const diagnostics: Diagnostic[] = []; + // Biome's JSON reporter is experimental and may reshape its output in + // patch releases. Track whether any diagnostic carried a usable location + // so a schema drift surfaces as a warning instead of a silent empty list. + let sawUsableLocation = false; + + for (const diag of emitted) { const location = diag.location; - if (!location?.path?.file) continue; + const filePath = location?.path; + if (!filePath) continue; + sawUsableLocation = true; - // Resolve file path - const diagFile = path.isAbsolute(location.path.file) - ? location.path.file - : path.join(this.cwd, location.path.file); + // Biome reports paths relative to its cwd. + const diagFile = path.isAbsolute(filePath) ? filePath : path.join(this.cwd, filePath); - // Only include diagnostics for the target file - if (path.resolve(diagFile) !== target) { - continue; - } + // Only include diagnostics for the target file. + if (path.resolve(diagFile) !== target) continue; - relevant.push(diag); - if (location.span && location.sourceCode) { - const offsets = offsetsBySource.get(location.sourceCode); - if (offsets) offsets.push(location.span[0], location.span[1]); - else offsetsBySource.set(location.sourceCode, [location.span[0], location.span[1]]); - } - } - - const positionsBySource = new Map>(); - for (const [source, offsets] of offsetsBySource) { - positionsBySource.set(source, offsetsToPositions(source, offsets)); - } - - for (const diag of relevant) { - const location = diag.location; - let startLine = 1; - let startColumn = 1; - let endLine = 1; - let endColumn = 1; - - if (location?.span && location.sourceCode) { - const positions = positionsBySource.get(location.sourceCode); - const startPos = positions?.get(location.span[0]); - const endPos = positions?.get(location.span[1]); - if (startPos) { - startLine = startPos.line; - startColumn = startPos.column; - } - if (endPos) { - endLine = endPos.line; - endColumn = endPos.column; - } - } + // Biome positions are 1-indexed; LSP ranges are 0-indexed. + const start = location.start; + const end = location.end ?? start; + const startLine = start?.line ?? 1; + const startColumn = start?.column ?? 1; + const endLine = end?.line ?? startLine; + const endColumn = end?.column ?? startColumn; diagnostics.push({ range: { @@ -248,12 +191,23 @@ export class BiomeClient implements LinterClient { end: { line: endLine - 1, character: endColumn - 1 }, }, severity: parseSeverity(diag.severity), - message: diag.description, + message: diag.message, source: "biome", code: diag.category, }); } + // Non-empty output whose diagnostics all lacked a recognizable location + // means the reporter schema changed out from under us — warn loudly + // instead of masking the regression as "no lint issues". + if (emitted.length > 0 && !sawUsableLocation) { + warnBiomeOnce( + `schema:${this.cwd}`, + "Biome diagnostics had no recognizable location; reporter schema may have changed", + { cwd: this.cwd, file: targetFile, count: emitted.length }, + ); + } + return diagnostics; } diff --git a/packages/coding-agent/test/biome-client.test.ts b/packages/coding-agent/test/biome-client.test.ts index b97b6a0db..3a731af70 100644 --- a/packages/coding-agent/test/biome-client.test.ts +++ b/packages/coding-agent/test/biome-client.test.ts @@ -130,3 +130,44 @@ describe("BiomeClient format", () => { expect(result).toBe(content); }); }); + +describe("BiomeClient lint", () => { + test("surfaces Biome 2.x --reporter=json diagnostics", async () => { + const tempDir = await makeTempDir(); + await Bun.write( + path.join(tempDir, "biome.json"), + `${JSON.stringify({ linter: { enabled: true, rules: { recommended: true } } })}\n`, + ); + const targetFile = path.join(tempDir, "lint-me.ts"); + // `x == 2` triggers lint/suspicious/noDoubleEquals (a recommended rule). + await Bun.write(targetFile, "const x: number = 1;\nif (x == 2) {\n}\n"); + + const diagnostics = await new BiomeClient(biomeConfig(repoBiome), tempDir).lint(targetFile); + + const doubleEquals = diagnostics.find(d => d.code === "lint/suspicious/noDoubleEquals"); + expect(doubleEquals).toBeDefined(); + expect(doubleEquals?.source).toBe("biome"); + expect(doubleEquals?.severity).toBe(1); + expect(doubleEquals?.message).toContain("=="); + // Biome reports `==` at line 2, columns 7-9 (1-indexed); LSP ranges are + // 0-indexed, so the mapping must land on line 1, characters 6-8. + expect(doubleEquals?.range).toEqual({ + start: { line: 1, character: 6 }, + end: { line: 1, character: 8 }, + }); + }); + + test("returns no diagnostics for a clean file", async () => { + const tempDir = await makeTempDir(); + await Bun.write( + path.join(tempDir, "biome.json"), + `${JSON.stringify({ linter: { enabled: true, rules: { recommended: true } } })}\n`, + ); + const targetFile = path.join(tempDir, "clean.ts"); + await Bun.write(targetFile, "export const value = 1;\n"); + + const diagnostics = await new BiomeClient(biomeConfig(repoBiome), tempDir).lint(targetFile); + + expect(diagnostics).toEqual([]); + }); +});