Merge remote-tracking branch 'origin/farm/38b90f8c/fix-biome-json-reporter-schema'
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user