Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions src/harness/check-engine/tool-runners/go.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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({
Expand All @@ -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([]);
Expand Down Expand Up @@ -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("./...");
});
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand All @@ -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);
});
});
38 changes: 36 additions & 2 deletions src/harness/check-engine/tool-runners/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------
Expand Down Expand Up @@ -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),
];
Expand All @@ -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")) {
Expand Down