Merge remote-tracking branch 'origin/farm/38b90f8c/fix-biome-json-reporter-schema'

This commit is contained in:
Can Bölük
2026-08-16 09:13:21 +03:00
3 changed files with 92 additions and 93 deletions
+4
View File
@@ -66,6 +66,10 @@
- Fixed `/vibe` cancellation leaving an in-flight model turn unaware that Vibe mode and its tools were removed ([#8326](https://github.com/can1357/oh-my-pi/issues/8326)).
- Fixed empty local-model stops lingering on the persisted active branch after retries; discarded turns now durably select their parent, preserve safe metadata children, and cannot resurface after reload or a mid-retry process kill. ([#5179](https://github.com/can1357/oh-my-pi/issues/5179))
### 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
@@ -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<number, { line: number; column: number }> {
const sorted = [...new Set(offsets)].sort((a, b) => a - b);
const result = new Map<number, { line: number; column: number }>();
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<string, number[]>();
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<string, Map<number, { line: number; column: number }>>();
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;
}
@@ -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([]);
});
});