Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/harness/check-engine/output-parsers-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }],
Expand Down
40 changes: 39 additions & 1 deletion src/harness/check-engine/output-parsers-extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
6 changes: 4 additions & 2 deletions src/harness/check-engine/tool-runners/__tests__/go.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
97 changes: 79 additions & 18 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 All @@ -226,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", () => {
Expand Down Expand Up @@ -337,8 +398,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 +413,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 +444,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 +468,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);
});
});
84 changes: 79 additions & 5 deletions src/harness/check-engine/tool-runners/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,74 @@ 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;
}

/**
* 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<Record<number, string>> = {
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"], {
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 +139,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,15 +152,19 @@ 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")) {
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 [];
Expand Down
Loading