From af9672c822edb40721368333c6253ccdafa7f641 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:41:02 -0600 Subject: [PATCH 1/3] fix: select golangci-lint JSON output flag by major version golangci-lint v2 removed --out-format. Probe `golangci-lint version` and pass --output.json.path=stdout on v2+, keeping --out-format=json for v1 and for unrecognized version text. --- .../tool-runners/go.integration.test.ts | 66 ++++++++++++++++--- src/harness/check-engine/tool-runners/go.ts | 38 ++++++++++- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/harness/check-engine/tool-runners/go.integration.test.ts b/src/harness/check-engine/tool-runners/go.integration.test.ts index 898bc47c..c57c38a3 100644 --- a/src/harness/check-engine/tool-runners/go.integration.test.ts +++ b/src/harness/check-engine/tool-runners/go.integration.test.ts @@ -20,7 +20,7 @@ vi.mock("node:child_process", () => ({ })); // Imported after the mock is registered. -const { runGoBuild, runGolangciLint } = await import("./go.js"); +const { golangciJsonFormatArg, runGoBuild, runGolangciLint } = await import("./go.js"); const PROJECT_ROOT = "/work/repo"; const TARGET = `${PROJECT_ROOT}/cmd/server/main.go`; @@ -200,12 +200,42 @@ describe("runGoBuild", () => { // runGolangciLint // --------------------------------------------------------------------------- +function golangciRunCall(): [string, string[], { env?: NodeJS.ProcessEnv }] { + const call = spawnSyncMock.mock.calls.find( + (entry) => Array.isArray(entry[1]) && entry[1][0] === "run", + ); + return nonNull(call) as [string, string[], { env?: NodeJS.ProcessEnv }]; +} + +describe("golangciJsonFormatArg", () => { + it("keeps the v1 flag for 1.x and unrecognized text", () => { + expect(golangciJsonFormatArg("golangci-lint has version 1.64.8 built with go1.24.1")).toBe( + "--out-format=json", + ); + expect(golangciJsonFormatArg("")).toBe("--out-format=json"); + }); + + it("uses the v2 flag for 2.x", () => { + expect(golangciJsonFormatArg("golangci-lint has version 2.13.2 built with go1.27.0")).toBe( + "--output.json.path=stdout", + ); + }); +}); + describe("runGolangciLint", () => { - it("invokes golangci-lint with json out-format scoped to the edited package", () => { - spawnSyncMock.mockReturnValue(spawnResult({ status: 0 })); + it("invokes golangci-lint v1 with json out-format scoped to the edited package", () => { + spawnSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "version") { + return spawnResult({ + status: 0, + stdout: "golangci-lint has version 1.64.8 built with go1.24.1\n", + }); + } + return spawnResult({ status: 0 }); + }); runGolangciLint(input(fileScope(), 7_777)); - expect(spawnSyncMock).toHaveBeenCalledTimes(1); - const [cmd, args, opts] = nonNull(spawnSyncMock.mock.calls[0]); + expect(spawnSyncMock).toHaveBeenCalledTimes(2); + const [cmd, args, opts] = golangciRunCall(); expect(cmd).toBe("golangci-lint"); expect(args).toEqual(["run", "--out-format=json", "./cmd/server"]); expect(opts).toMatchObject({ @@ -216,6 +246,22 @@ describe("runGolangciLint", () => { }); }); + it("invokes golangci-lint v2 with --output.json.path=stdout", () => { + spawnSyncMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "version") { + return spawnResult({ + status: 0, + stdout: "golangci-lint has version 2.13.2 built with go1.27.0\n", + }); + } + return spawnResult({ status: 1, stdout: golangciJson() }); + }); + const out = runGolangciLint(input(fileScope())); + expect(golangciRunCall()[1]).toEqual(["run", "--output.json.path=stdout", "./cmd/server"]); + expect(out).toHaveLength(1); + expect(nonNull(out[0]).ruleId).toBe("errcheck"); + }); + it("returns [] when the binary is absent (error.code === ENOENT)", () => { spawnSyncMock.mockReturnValue(spawnResult({ status: null, error: enoent() })); expect(runGolangciLint(input(fileScope()))).toEqual([]); @@ -337,8 +383,8 @@ describe("Go package scoping — positive (must fire)", () => { it("P2: golangci-lint narrows to the edited package in filtered file mode", () => { runGolangciLint(input(fileScope())); - expect(recordedArgs()).toContain("./cmd/server"); - expect(recordedArgs()).not.toContain("./..."); + expect(golangciRunCall()[1]).toContain("./cmd/server"); + expect(golangciRunCall()[1]).not.toContain("./..."); }); }); @@ -352,7 +398,7 @@ describe("Go package scoping — negative (must not fire)", () => { it("N2: project mode still lints the whole module (golangci-lint)", () => { runGolangciLint(input(fileScope({ mode: "project" }))); - expect(recordedArgs()).toEqual(["run", "--out-format=json", "./..."]); + expect(golangciRunCall()[1]).toEqual(["run", "--out-format=json", "./..."]); }); it("N3: file mode WITHOUT filterToFile keeps the whole module — narrowing there would drop findings", () => { @@ -383,7 +429,7 @@ describe("Go build-tag + environment parity", () => { it("P2: threads --build-tags into golangci-lint (it ignores GOFLAGS -tags)", () => { process.env.INTERLINKED_GOFLAGS = "-tags=integration"; runGolangciLint(input(fileScope())); - expect(recordedArgs()).toEqual([ + expect(golangciRunCall()[1]).toEqual([ "run", "--out-format=json", "--build-tags=integration", @@ -407,7 +453,7 @@ describe("Go build-tag + environment parity", () => { it("N2: does not invent a GOCACHE when none is configured", () => { delete process.env.INTERLINKED_GOCACHE; runGolangciLint(input(fileScope())); - const env = nonNull(recordedOpts().env); + const env = nonNull(golangciRunCall()[2].env); expect(env.GOCACHE).toBe(process.env.GOCACHE); }); }); diff --git a/src/harness/check-engine/tool-runners/go.ts b/src/harness/check-engine/tool-runners/go.ts index b4ce6e22..73d1c7c4 100644 --- a/src/harness/check-engine/tool-runners/go.ts +++ b/src/harness/check-engine/tool-runners/go.ts @@ -18,6 +18,38 @@ import { resolveGoEnv, } from "./go-invocation.js"; +/** golangci-lint v1 JSON flag. v2 removed it (`unknown flag: --out-format`, exit 3). */ +const GOLANGCI_V1_JSON = "--out-format=json"; +/** golangci-lint v2+ JSON flag. Text stays off stdout when only this path is set. */ +const GOLANGCI_V2_JSON = "--output.json.path=stdout"; + +/** + * Pick the JSON output flag from `golangci-lint version` text. + * Unrecognized output stays on the v1 flag so older binaries keep working. + */ +export function golangciJsonFormatArg(versionOutput: string): string { + const match = + versionOutput.match(/\bversion\s+(\d+)\./i) ?? versionOutput.match(/\b(\d+)\.\d+\.\d+\b/); + const major = match ? Number(match[1]) : 1; + return major >= 2 ? GOLANGCI_V2_JSON : GOLANGCI_V1_JSON; +} + +function probeGolangciFormatArg(cwd: string, timeoutMs: number, env: NodeJS.ProcessEnv): string { + try { + const result = spawnSync("golangci-lint", ["version"], { + cwd, + timeout: Math.min(timeoutMs, 10_000), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + }); + if (result.error) return GOLANGCI_V1_JSON; + return golangciJsonFormatArg(`${result.stdout ?? ""}\n${result.stderr ?? ""}`); + } catch { + return GOLANGCI_V1_JSON; + } +} + // ------------------------------------------- // go build // ------------------------------------------- @@ -71,9 +103,11 @@ export function runGolangciLint(input: ToolRunnerInput): CheckResult[] { // is threaded explicitly because golangci-lint does NOT read `-tags` // from GOFLAGS — without it its loader sees a different file set than // `go build` and pays for a separate type-check. + const env = resolveGoEnv(process.env); + const formatArg = probeGolangciFormatArg(scope.projectRoot, timeoutMs, env); const args = [ "run", - "--out-format=json", + formatArg, ...golangciBuildTagArgs(goToolTags(process.env)), goPackagePattern(scope), ]; @@ -82,7 +116,7 @@ export function runGolangciLint(input: ToolRunnerInput): CheckResult[] { timeout: timeoutMs, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], - env: resolveGoEnv(process.env), + env, }); if (hasErrorCode(result.error, "ENOENT")) { From dc7233647456e2b8e3910438e96cc26d064515d2 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:41:24 -0600 Subject: [PATCH 2/3] fix: report golangci-lint analysis failures instead of a clean run Exit codes 3 through 7 mean the analysis did not finish. Return a no-verdict warning for those codes and for a timed-out process. Exit 0 stays clean, and issue output is still parsed. --- .../tool-runners/__tests__/go.test.ts | 6 ++- .../tool-runners/go.integration.test.ts | 31 +++++++++---- src/harness/check-engine/tool-runners/go.ts | 46 +++++++++++++++++-- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/harness/check-engine/tool-runners/__tests__/go.test.ts b/src/harness/check-engine/tool-runners/__tests__/go.test.ts index 08f89004..b223047f 100644 --- a/src/harness/check-engine/tool-runners/__tests__/go.test.ts +++ b/src/harness/check-engine/tool-runners/__tests__/go.test.ts @@ -25,8 +25,10 @@ describe("Go runners", () => { expect(runGoBuild(input())).toEqual([]); }); - it("runGolangciLint is a function + returns [] without go.mod", () => { + it("runGolangciLint is a function and does not report a missing module as clean", () => { expect(typeof runGolangciLint).toBe("function"); - expect(runGolangciLint(input())).toEqual([]); + const out = runGolangciLint(input()); + // Absent binary stays []; a real failure must be a no-verdict warning. + expect(out.every((finding) => finding.message.includes("did not produce a verdict"))).toBe(true); }); }); diff --git a/src/harness/check-engine/tool-runners/go.integration.test.ts b/src/harness/check-engine/tool-runners/go.integration.test.ts index c57c38a3..694456d4 100644 --- a/src/harness/check-engine/tool-runners/go.integration.test.ts +++ b/src/harness/check-engine/tool-runners/go.integration.test.ts @@ -272,14 +272,29 @@ describe("runGolangciLint", () => { expect(runGolangciLint(input(fileScope()))).toEqual([]); }); - it("returns [] when status === 3 (analysis failure — skipped silently)", () => { - spawnSyncMock.mockReturnValue(spawnResult({ status: 3, stdout: golangciJson() })); - expect(runGolangciLint(input(fileScope()))).toEqual([]); - }); - - it("returns [] when status === 4 (timeout — skipped silently)", () => { - spawnSyncMock.mockReturnValue(spawnResult({ status: 4, stdout: golangciJson() })); - expect(runGolangciLint(input(fileScope()))).toEqual([]); + it("warns instead of reporting clean when status is 3, 4, 5, 6, or 7", () => { + for (const status of [3, 4, 5, 6, 7]) { + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue( + spawnResult({ status, stdout: golangciJson(), stderr: "failed to analyze\n" }), + ); + const out = runGolangciLint(input(fileScope())); + expect(out).toHaveLength(1); + expect(nonNull(out[0]).tool).toBe("golangci-lint"); + expect(nonNull(out[0]).severity).toBe("warning"); + expect(nonNull(out[0]).message).toContain(`exit ${status}`); + expect(nonNull(out[0]).message).toContain("did not produce a verdict"); + expect(nonNull(out[0]).message).toContain("failed to analyze"); + } + }); + + it("warns when spawnSync times out", () => { + const error = new Error("timed out") as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + spawnSyncMock.mockReturnValue(spawnResult({ status: null, error })); + const out = runGolangciLint(input(fileScope())); + expect(out).toHaveLength(1); + expect(nonNull(out[0]).message).toContain("timed out"); }); it("returns [] when status === 1 but stdout is empty after trim", () => { diff --git a/src/harness/check-engine/tool-runners/go.ts b/src/harness/check-engine/tool-runners/go.ts index 73d1c7c4..a574a313 100644 --- a/src/harness/check-engine/tool-runners/go.ts +++ b/src/harness/check-engine/tool-runners/go.ts @@ -34,6 +34,42 @@ export function golangciJsonFormatArg(versionOutput: string): string { return major >= 2 ? GOLANGCI_V2_JSON : GOLANGCI_V1_JSON; } +/** + * golangci-lint `pkg/exitcodes` values that mean the analysis did not finish. + * These are not an empty issue list. Exit 0 is clean. Exit 1 (IssuesFound) + * and any other code still carry JSON issues. + */ +const GOLANGCI_NO_VERDICT_STATUS: Readonly> = { + 3: "analysis failed (exit 3)", + 4: "timed out (exit 4)", + 5: "no Go files to analyze (exit 5)", + 6: "no config file detected (exit 6)", + 7: "an error was logged (exit 7)", +}; + +function golangciNoVerdict(reason: string, detail?: string): CheckResult[] { + const line = detail?.trim().split("\n").find((row) => row.trim().length > 0); + const clipped = line === undefined ? undefined : line.trim().slice(0, 200); + const suffix = clipped === undefined || clipped.length === 0 ? "" : ` (${clipped})`; + return [ + { + tool: "golangci-lint", + severity: "warning", + file: "", + line: 0, + message: `golangci-lint did not produce a verdict: ${reason}${suffix}`, + }, + ]; +} + +/** `null` means the caller should parse JSON issues. */ +function golangciUnfinished(status: number | null, detail?: string): CheckResult[] | null { + if (status === null) return golangciNoVerdict("process did not exit"); + const reason = GOLANGCI_NO_VERDICT_STATUS[status]; + if (reason === undefined) return null; + return golangciNoVerdict(reason, detail); +} + function probeGolangciFormatArg(cwd: string, timeoutMs: number, env: NodeJS.ProcessEnv): string { try { const result = spawnSync("golangci-lint", ["version"], { @@ -122,9 +158,13 @@ export function runGolangciLint(input: ToolRunnerInput): CheckResult[] { if (hasErrorCode(result.error, "ENOENT")) { return []; } - // Exit 0 = clean, exit 1 = issues found - // Exit 3 = analysis failure, exit 4 = timeout — skip silently - if (result.status === 0 || result.status === 3 || result.status === 4) return []; + if (hasErrorCode(result.error, "ETIMEDOUT")) { + return golangciNoVerdict("timed out"); + } + // Exit 0 = clean. Exit 1 = issues found (JSON on stdout). + if (result.status === 0) return []; + const unfinished = golangciUnfinished(result.status, result.stderr || ""); + if (unfinished !== null) return unfinished; const output = (result.stdout || "").trim(); if (!output) return []; From 6343dd52e1f92bbf08ce87917465cb25e23bd815 Mon Sep 17 00:00:00 2001 From: DatScreamer <17242089+DatScreamer@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:12:56 -0600 Subject: [PATCH 3/3] fix: parse golangci-lint JSON when a text trailer follows it golangci-lint v2 can write the JSON report and then a text summary on the same stdout. JSON.parse rejected that trailer and the parser returned no findings. Read the first JSON object and ignore the trailer. --- .../check-engine/output-parsers-extra.test.ts | 15 +++++++ .../check-engine/output-parsers-extra.ts | 40 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/harness/check-engine/output-parsers-extra.test.ts b/src/harness/check-engine/output-parsers-extra.test.ts index 9d048881..826999a8 100644 --- a/src/harness/check-engine/output-parsers-extra.test.ts +++ b/src/harness/check-engine/output-parsers-extra.test.ts @@ -456,6 +456,21 @@ describe("parseCargoJson", () => { }); describe("parseGolangciLintJson", () => { + it("keeps issues when a text trailer follows the JSON object", () => { + const payload = JSON.stringify({ + Issues: [ + { + FromLinter: "govet", + Text: 'brace in text { and }', + Pos: { Filename: "a.go", Line: 4, Column: 1 }, + }, + ], + }); + const results = parseGolangciLintJson(`${payload}\n1834 issues:\n`); + expect(results).toHaveLength(1); + expect(nonNull(results[0])).toMatchObject({ file: "a.go", line: 4, ruleId: "govet" }); + }); + it("reads file/line from issue.Pos when present", () => { const payload = JSON.stringify({ Issues: [{ FromLinter: "govet", Text: "bad", Pos: { Filename: "a.go", Line: 10, Column: 2 } }], diff --git a/src/harness/check-engine/output-parsers-extra.ts b/src/harness/check-engine/output-parsers-extra.ts index a83ce8a0..f9308833 100644 --- a/src/harness/check-engine/output-parsers-extra.ts +++ b/src/harness/check-engine/output-parsers-extra.ts @@ -365,9 +365,47 @@ function parseGolangciIssue(value: unknown): GolangciIssue | null { }; } +/** + * golangci-lint v2 can write the JSON report and then a text trailer + * (`N issues:`) on the same stdout. `JSON.parse` rejects that trailer. + * Take the first JSON object and ignore the rest. + */ +function firstJsonObject(output: string): string | null { + const start = output.indexOf("{"); + if (start < 0) return null; + let depth = 0; + let inString = false; + let escape = false; + for (let i = start; i < output.length; i++) { + const ch = output[i]; + if (inString) { + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return output.slice(start, i + 1); + } + } + return null; +} + export function parseGolangciLintJson(output: string): CheckResult[] { try { - const parsed = JSON.parse(output); + const parsed = JSON.parse(firstJsonObject(output) ?? output); if (!isJsonObject(parsed)) return []; const issues = parsed.Issues; if (!Array.isArray(issues)) return [];