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] 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")) {