extension/test: add regression test for golang/vscode-go#3511

Also move the diagnostics test to the same file.

For golang/vscode-go#3511

Change-Id: I4797671b9955ce1d4d774b34474729d179d6cba1
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/812360
Auto-Submit: Hongxiang Jiang <hxjiang@golang.org>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Madeline Kalil <mkalil@google.com>
diff --git a/extension/test/gopls/diagnostics.test.ts b/extension/test/gopls/diagnostics.test.ts
new file mode 100644
index 0000000..c83ee4e
--- /dev/null
+++ b/extension/test/gopls/diagnostics.test.ts
@@ -0,0 +1,323 @@
+/*---------------------------------------------------------
+ * Copyright 2026 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+
+import assert from 'assert';
+import os = require('os');
+import * as path from 'path';
+import sinon from 'sinon';
+import * as vscode from 'vscode';
+import * as config from '../../src/config';
+import { GoExtensionContext } from '../../src/context';
+import { handleErrors, ICheckResult } from '../../src/diagnostics/diagnostics';
+import { lintCode } from '../../src/diagnostics/goLint';
+import { MockWorkspaceConfiguration } from '../integration/mocks/configuration';
+import { Env } from './goplsTestEnv.utils';
+
+interface expectedDiagnostic {
+	line: number;
+	source: string;
+	severity: vscode.DiagnosticSeverity;
+}
+
+function compareDiags(a: vscode.Diagnostic, b: vscode.Diagnostic): number {
+	if (a.range.start.line !== b.range.start.line) {
+		return a.range.start.line - b.range.start.line;
+	}
+	if (a.range.start.character !== b.range.start.character) {
+		return a.range.start.character - b.range.start.character;
+	}
+	if (a.severity !== b.severity) {
+		return a.severity - b.severity;
+	}
+	return (a.source ?? '').localeCompare(b.source ?? '');
+}
+
+suite('Diagnostic consolidation - unit', () => {
+	let goCtx: GoExtensionContext;
+
+	const fileURI = vscode.Uri.file(path.join(os.tmpdir(), 'diagnostic_priority_test.go')); // fake file
+	const filePath = fileURI.fsPath;
+
+	interface TestDiagnosticTestCase {
+		name: string;
+		diags: {
+			gopls?: ICheckResult[];
+			build?: ICheckResult[];
+			vet?: ICheckResult[];
+			lint?: ICheckResult[];
+		};
+		want: expectedDiagnostic[];
+	}
+
+	const testCases: TestDiagnosticTestCase[] = [
+		{
+			name: 'Symmetric priority masking (Gopls > Build > Vet > Lint)',
+			diags: {
+				gopls: [{ file: filePath, line: 10, msg: 'unmasked - highest priority', severity: 'warning' }],
+				build: [
+					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
+					{ file: filePath, line: 20, msg: 'unmasked - no higher priority', severity: 'warning' }
+				],
+				vet: [
+					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
+					{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
+					{ file: filePath, line: 30, msg: 'unmasked - no higher priority', severity: 'warning' }
+				],
+				lint: [
+					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
+					{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
+					{ file: filePath, line: 30, msg: 'masked by vet', severity: 'warning' },
+					{ file: filePath, line: 40, msg: 'unmasked - no higher priority', severity: 'warning' }
+				]
+			},
+			want: [
+				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning },
+				{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Warning },
+				{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
+				{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
+			]
+		},
+		{
+			name: 'Diagnostics with columns and severity',
+			diags: {
+				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
+				build: [
+					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
+					{ file: filePath, line: 20, col: 12, msg: 'unmasked - no higher priority', severity: 'error' }
+				],
+				vet: [
+					{ file: filePath, line: 20, col: 12, msg: 'masked by build', severity: 'warning' },
+					{ file: filePath, line: 30, col: 8, msg: 'unmasked - no higher priority', severity: 'warning' }
+				],
+				lint: [
+					{ file: filePath, line: 30, col: 8, msg: 'masked by vet', severity: 'warning' },
+					{ file: filePath, line: 40, col: 15, msg: 'unmasked - no higher priority', severity: 'warning' }
+				]
+			},
+			want: [
+				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error },
+				{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Error },
+				{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
+				{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
+			]
+		},
+		// TODO(hxjiang): update test case once dedup based on line and column
+		{
+			name: 'Same line, different columns, same severity',
+			diags: {
+				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
+				build: [
+					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
+					{ file: filePath, line: 10, col: 15, msg: 'masked by gopls', severity: 'error' }
+				],
+				vet: [{ file: filePath, line: 10, col: 25, msg: 'masked by gopls', severity: 'error' }],
+				lint: [{ file: filePath, line: 10, col: 35, msg: 'masked by gopls', severity: 'error' }]
+			},
+			want: [{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error }]
+		},
+		{
+			name: 'Same line and column, lower priority has higher severity',
+			diags: {
+				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'warning' }],
+				lint: [
+					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'warning' },
+					{
+						file: filePath,
+						line: 10,
+						col: 5,
+						msg: 'unmasked - higher severity than gopls warning',
+						severity: 'error'
+					}
+				]
+			},
+			want: [
+				{ line: 10, source: 'lint-test', severity: vscode.DiagnosticSeverity.Error },
+				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning }
+			]
+		}
+	];
+
+	setup(() => {
+		goCtx = {
+			languageClient: { diagnostics: vscode.languages.createDiagnosticCollection('gopls-test') } as any,
+			buildDiagnosticCollection: vscode.languages.createDiagnosticCollection('build-test'),
+			vetDiagnosticCollection: vscode.languages.createDiagnosticCollection('vet-test'),
+			lintDiagnosticCollection: vscode.languages.createDiagnosticCollection('lint-test')
+		};
+	});
+
+	teardown(() => {
+		goCtx.languageClient?.diagnostics?.dispose();
+		goCtx.buildDiagnosticCollection?.dispose();
+		goCtx.vetDiagnosticCollection?.dispose();
+		goCtx.lintDiagnosticCollection?.dispose();
+	});
+
+	for (const tc of testCases) {
+		for (let round = 1; round <= 5; round++) {
+			test(`${tc.name} (round ${round})`, async () => {
+				const collections = [
+					{ key: 'gopls' as const, collection: goCtx.languageClient!.diagnostics! },
+					{ key: 'build' as const, collection: goCtx.buildDiagnosticCollection! },
+					{ key: 'vet' as const, collection: goCtx.vetDiagnosticCollection! },
+					{ key: 'lint' as const, collection: goCtx.lintDiagnosticCollection! }
+				];
+
+				// Simulate 4 concurrent diagnostic providers reporting diags
+				// independently with random delays to verify eventual consistency.
+				const tasks = collections.map(({ key, collection }) => {
+					const errors = tc.diags[key] || [];
+					return new Promise<void>((resolve) => {
+						const delay = Math.floor(Math.random() * 15);
+						setTimeout(() => {
+							handleErrors(goCtx, undefined, errors, collection);
+							resolve();
+						}, delay);
+					});
+				});
+
+				// Wait for all concurrent diagnostic providers to finish reporting.
+				await Promise.all(tasks);
+
+				// Read diagnostics directly from the "PROBLEMS" tab.
+				const problems = vscode.languages.getDiagnostics(fileURI);
+				const sorted = [...problems].sort(compareDiags);
+
+				assert.strictEqual(
+					sorted.length,
+					tc.want.length,
+					`[${tc.name}] Expected ${tc.want.length} diagnostics, got ${sorted.length}: ${JSON.stringify(sorted.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message })))}`
+				);
+
+				for (let i = 0; i < tc.want.length; i++) {
+					const want = tc.want[i];
+					const got = sorted[i];
+					assert.strictEqual(got.range.start.line, want.line - 1, `[${tc.name}] Line mismatch at index ${i}`);
+					assert.strictEqual(got.source, want.source, `[${tc.name}] Source mismatch at index ${i}`);
+					assert.strictEqual(got.severity, want.severity, `[${tc.name}] Severity mismatch at index ${i}`);
+				}
+			});
+		}
+	}
+});
+
+// Regression tests for golang/vscode-go#3511.
+suite('Diagnostic consolidation - regression (#3511)', function () {
+	this.timeout(30000);
+	const projectDir = path.join(__dirname, '..', '..', '..');
+	const testdataDir = path.join(projectDir, 'test', 'testdata', 'diagnosticsTest');
+	let env: Env;
+
+	async function pollDiagnostics(uri: vscode.Uri, predicate: (diags: vscode.Diagnostic[]) => boolean): Promise<void> {
+		const start = Date.now();
+		// Polling deadline of 10s. Analyzing a module with only a few files
+		// should finish within 10s for both gopls and external linters.
+		while (Date.now() - start < 10000) {
+			const problems = vscode.languages.getDiagnostics(uri);
+			if (predicate(problems)) {
+				return;
+			}
+			await new Promise((resolve) => setTimeout(resolve, 50));
+		}
+		assert.fail(
+			`timed out waiting for expected diags on ${path.basename(uri.fsPath)}, got: ${JSON.stringify(
+				vscode.languages
+					.getDiagnostics(uri)
+					.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message }))
+			)}`
+		);
+	}
+
+	suiteSetup(async () => {
+		env = new Env();
+		const goplsConfig = new MockWorkspaceConfiguration(
+			config.getGoplsConfig(),
+			new Map<string, any>([
+				['ui.diagnostic.staticcheck', true],
+				['ui.diagnostic.analyses', { ST1017: true }]
+			])
+		);
+		sinon.stub(config, 'getGoplsConfig').returns(goplsConfig);
+
+		const goConfig = new MockWorkspaceConfiguration(
+			config.getGoConfig(),
+			new Map<string, any>([
+				['lintTool', 'golangci-lint-v2'],
+				['lintOnSave', 'package']
+			])
+		);
+		sinon.stub(config, 'getGoConfig').returns(goConfig);
+
+		env.goCtx.lintDiagnosticCollection = vscode.languages.createDiagnosticCollection('go-lint');
+
+		// Start gopls with workspace
+		await env.startGopls(path.join(testdataDir, 'masked.go'), undefined, testdataDir);
+
+		// Open both documents to trigger gopls diagnostics
+		const { doc: coexistDoc } = await env.openDoc(path.join(testdataDir, 'coexist.go'));
+		const { doc: maskedDoc } = await env.openDoc(path.join(testdataDir, 'masked.go'));
+		await vscode.window.showTextDocument(coexistDoc);
+
+		// Run linter once for the package
+		lintCode('package')(undefined as any, env.goCtx)();
+
+		// Wait until diagnostics from both gopls and linter are ready
+		await pollDiagnostics(coexistDoc.uri, (diags) => diags.length >= 2);
+		await pollDiagnostics(maskedDoc.uri, (diags) => diags.length >= 1);
+	});
+
+	suiteTeardown(async () => {
+		sinon.restore();
+		env.goCtx.lintDiagnosticCollection?.dispose();
+		await env.teardown();
+		env.flushTrace(false);
+	});
+
+	interface DiagnosticTestCase {
+		name: string;
+		fileName: string;
+		want: expectedDiagnostic[];
+	}
+
+	const testCases: DiagnosticTestCase[] = [
+		{
+			name: 'coexist',
+			fileName: 'coexist.go',
+			// golangci-lint-v2 report a more severe diags so that persist.
+			want: [
+				{ line: 8, source: 'any', severity: vscode.DiagnosticSeverity.Hint },
+				{ line: 8, source: 'go-lint', severity: vscode.DiagnosticSeverity.Warning }
+			]
+		},
+		{
+			name: 'masked',
+			fileName: 'masked.go',
+			// golangci-lint-v2 will report the same diags but prefer gopls'.
+			want: [{ line: 4, source: 'ST1017', severity: vscode.DiagnosticSeverity.Warning }]
+		}
+	];
+
+	for (const tc of testCases) {
+		test(tc.name, () => {
+			const uri = vscode.Uri.file(path.join(testdataDir, tc.fileName));
+			const problems = vscode.languages.getDiagnostics(uri);
+			const sorted = [...problems].sort(compareDiags);
+
+			assert.strictEqual(
+				sorted.length,
+				tc.want.length,
+				`[${tc.name}] Expected ${tc.want.length} diagnostics, got ${sorted.length}: ${JSON.stringify(sorted.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message })))}`
+			);
+
+			for (let i = 0; i < tc.want.length; i++) {
+				const want = tc.want[i];
+				const got = sorted[i];
+				assert.strictEqual(got.range.start.line, want.line - 1, `[${tc.name}] Line mismatch at index ${i}`);
+				assert.strictEqual(got.source, want.source, `[${tc.name}] Source mismatch at index ${i}`);
+				assert.strictEqual(got.severity, want.severity, `[${tc.name}] Severity mismatch at index ${i}`);
+			}
+		});
+	}
+});
diff --git a/extension/test/integration/diagnostics.test.ts b/extension/test/integration/diagnostics.test.ts
deleted file mode 100644
index 825cf70..0000000
--- a/extension/test/integration/diagnostics.test.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-/*---------------------------------------------------------
- * Copyright 2026 The Go Authors. All rights reserved.
- * Licensed under the MIT License. See LICENSE in the project root for license information.
- *--------------------------------------------------------*/
-
-import assert from 'assert';
-import os = require('os');
-import path = require('path');
-import * as vscode from 'vscode';
-import { GoExtensionContext } from '../../src/context';
-import { handleErrors, ICheckResult } from '../../src/diagnostics/diagnostics';
-
-interface DiagnosticTestCase {
-	name: string;
-	diags: {
-		gopls?: ICheckResult[];
-		build?: ICheckResult[];
-		vet?: ICheckResult[];
-		lint?: ICheckResult[];
-	};
-	want: {
-		line: number;
-		source: string;
-		severity: vscode.DiagnosticSeverity;
-	}[];
-}
-
-suite('Diagnostic consolidation', () => {
-	let goCtx: GoExtensionContext;
-
-	const fileURI = vscode.Uri.file(path.join(os.tmpdir(), 'diagnostic_priority_test.go')); // fake file
-	const filePath = fileURI.fsPath;
-
-	const testCases: DiagnosticTestCase[] = [
-		{
-			name: 'Symmetric priority masking (Gopls > Build > Vet > Lint)',
-			diags: {
-				gopls: [{ file: filePath, line: 10, msg: 'unmasked - highest priority', severity: 'warning' }],
-				build: [
-					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
-					{ file: filePath, line: 20, msg: 'unmasked - no higher priority', severity: 'warning' }
-				],
-				vet: [
-					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
-					{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
-					{ file: filePath, line: 30, msg: 'unmasked - no higher priority', severity: 'warning' }
-				],
-				lint: [
-					{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
-					{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
-					{ file: filePath, line: 30, msg: 'masked by vet', severity: 'warning' },
-					{ file: filePath, line: 40, msg: 'unmasked - no higher priority', severity: 'warning' }
-				]
-			},
-			want: [
-				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning },
-				{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Warning },
-				{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
-				{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
-			]
-		},
-		{
-			name: 'Diagnostics with columns and severity',
-			diags: {
-				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
-				build: [
-					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
-					{ file: filePath, line: 20, col: 12, msg: 'unmasked - no higher priority', severity: 'error' }
-				],
-				vet: [
-					{ file: filePath, line: 20, col: 12, msg: 'masked by build', severity: 'warning' },
-					{ file: filePath, line: 30, col: 8, msg: 'unmasked - no higher priority', severity: 'warning' }
-				],
-				lint: [
-					{ file: filePath, line: 30, col: 8, msg: 'masked by vet', severity: 'warning' },
-					{ file: filePath, line: 40, col: 15, msg: 'unmasked - no higher priority', severity: 'warning' }
-				]
-			},
-			want: [
-				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error },
-				{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Error },
-				{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
-				{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
-			]
-		},
-		// TODO(hxjiang): update test case once dedup based on line and column
-		{
-			name: 'Same line, different columns, same severity',
-			diags: {
-				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
-				build: [
-					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
-					{ file: filePath, line: 10, col: 15, msg: 'masked by gopls', severity: 'error' }
-				],
-				vet: [{ file: filePath, line: 10, col: 25, msg: 'masked by gopls', severity: 'error' }],
-				lint: [{ file: filePath, line: 10, col: 35, msg: 'masked by gopls', severity: 'error' }]
-			},
-			want: [{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error }]
-		},
-		{
-			name: 'Same line and column, lower priority has higher severity',
-			diags: {
-				gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'warning' }],
-				lint: [
-					{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'warning' },
-					{
-						file: filePath,
-						line: 10,
-						col: 5,
-						msg: 'unmasked - higher severity than gopls warning',
-						severity: 'error'
-					}
-				]
-			},
-			want: [
-				{ line: 10, source: 'lint-test', severity: vscode.DiagnosticSeverity.Error },
-				{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning }
-			]
-		}
-	];
-
-	setup(() => {
-		goCtx = {
-			languageClient: { diagnostics: vscode.languages.createDiagnosticCollection('gopls-test') } as any,
-			buildDiagnosticCollection: vscode.languages.createDiagnosticCollection('build-test'),
-			vetDiagnosticCollection: vscode.languages.createDiagnosticCollection('vet-test'),
-			lintDiagnosticCollection: vscode.languages.createDiagnosticCollection('lint-test')
-		};
-	});
-
-	teardown(() => {
-		goCtx.languageClient?.diagnostics?.dispose();
-		goCtx.buildDiagnosticCollection?.dispose();
-		goCtx.vetDiagnosticCollection?.dispose();
-		goCtx.lintDiagnosticCollection?.dispose();
-	});
-
-	for (const tc of testCases) {
-		for (let round = 1; round <= 5; round++) {
-			test(`${tc.name} (round ${round})`, async () => {
-				const collections = [
-					{ key: 'gopls' as const, collection: goCtx.languageClient!.diagnostics! },
-					{ key: 'build' as const, collection: goCtx.buildDiagnosticCollection! },
-					{ key: 'vet' as const, collection: goCtx.vetDiagnosticCollection! },
-					{ key: 'lint' as const, collection: goCtx.lintDiagnosticCollection! }
-				];
-
-				// Simulate 4 concurrent diagnostic providers reporting diags
-				// independently with random delays to verify eventual consistency.
-				const tasks = collections.map(({ key, collection }) => {
-					const errors = tc.diags[key] || [];
-					return new Promise<void>((resolve) => {
-						const delay = Math.floor(Math.random() * 15);
-						setTimeout(() => {
-							handleErrors(goCtx, undefined, errors, collection);
-							resolve();
-						}, delay);
-					});
-				});
-
-				// Wait for all concurrent diagnostic providers to finish reporting.
-				await Promise.all(tasks);
-
-				// Read diagnostics directly from the "PROBLEMS" tab.
-				const problems = vscode.languages.getDiagnostics(fileURI);
-
-				const sorted = [...problems].sort((a, b) => {
-					if (a.range.start.line !== b.range.start.line) {
-						return a.range.start.line - b.range.start.line;
-					}
-					if (a.range.start.character !== b.range.start.character) {
-						return a.range.start.character - b.range.start.character;
-					}
-					if (a.severity !== b.severity) {
-						return a.severity - b.severity;
-					}
-					return a.source!.localeCompare(b.source!);
-				});
-
-				assert.strictEqual(
-					sorted.length,
-					tc.want.length,
-					`[${tc.name}] Expected ${tc.want.length} diagnostics in problem tab, got ${sorted.length}: ${JSON.stringify(sorted.map((p) => ({ source: p.source, msg: p.message })))}`
-				);
-
-				for (let i = 0; i < tc.want.length; i++) {
-					const want = tc.want[i];
-					const got = sorted[i];
-					assert.strictEqual(got.range.start.line, want.line - 1, `[${tc.name}] Line mismatch at index ${i}`);
-					assert.strictEqual(got.source, want.source, `[${tc.name}] Source mismatch at index ${i}`);
-					assert.strictEqual(got.severity, want.severity, `[${tc.name}] Severity mismatch at index ${i}`);
-				}
-			});
-		}
-	}
-});
diff --git a/extension/test/testdata/diagnosticsTest/coexist.go b/extension/test/testdata/diagnosticsTest/coexist.go
new file mode 100644
index 0000000..7eb93c5
--- /dev/null
+++ b/extension/test/testdata/diagnosticsTest/coexist.go
@@ -0,0 +1,8 @@
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func Save(v interface{}, path string) { os.WriteFile(path, fmt.Append(nil, v), 0o600) }
diff --git a/extension/test/testdata/diagnosticsTest/go.mod b/extension/test/testdata/diagnosticsTest/go.mod
new file mode 100644
index 0000000..6dbf814
--- /dev/null
+++ b/extension/test/testdata/diagnosticsTest/go.mod
@@ -0,0 +1,3 @@
+module example.com/diagnosticstest
+
+go 1.20
diff --git a/extension/test/testdata/diagnosticsTest/masked.go b/extension/test/testdata/diagnosticsTest/masked.go
new file mode 100644
index 0000000..70a6873
--- /dev/null
+++ b/extension/test/testdata/diagnosticsTest/masked.go
@@ -0,0 +1,7 @@
+package main
+
+func Yoda(a int) {
+	if 42 == a {
+		_ = a
+	}
+}
diff --git a/extension/tools/allTools.ts.in b/extension/tools/allTools.ts.in
index 4e5f99f..19eabef 100644
--- a/extension/tools/allTools.ts.in
+++ b/extension/tools/allTools.ts.in
@@ -107,7 +107,8 @@
 			replacedByGopls: false,
 			isImportant: true,
 			description: 'Linter',
-			minimumGoVersion: semver.coerce('1.23')
+			minimumGoVersion: semver.coerce('1.23'),
+			defaultVersion: 'v2.12.2'
 		}
 	],
 	[
diff --git a/extension/tools/installtools/main.go b/extension/tools/installtools/main.go
index 8696345..81fe64d 100644
--- a/extension/tools/installtools/main.go
+++ b/extension/tools/installtools/main.go
@@ -24,41 +24,20 @@
 	"strings"
 )
 
-// finalVersion encodes the fact that the specified tool version
-// is the known last version that can be buildable with goMinorVersion.
-type finalVersion struct {
-	goMinorVersion int
-	version        string
-}
-
 var tools = []struct {
 	path          string
 	dest          string
 	preferPreview bool
-	// versions is a list of supportedVersions sorted by
-	// goMinorVersion. If we want to pin a tool's version
-	// add a fake entry with a large goMinorVersion
-	// value and the pinned tool version as the last entry.
-	// Nil of empty list indicates we can use the `latest` version.
-	versions []finalVersion
+	version       string // pinned version, or empty string to use "latest" (or preview if preferPreview)
 }{
 	// TODO: auto-generate based on allTools.ts.in.
-	{"golang.org/x/tools/gopls", "", true, nil},
-	{"github.com/cweill/gotests/gotests", "", false, nil},
-	{"github.com/haya14busa/goplay/cmd/goplay", "", false, nil},
-	{"honnef.co/go/tools/cmd/staticcheck", "", false, []finalVersion{{21, "v0.4.7"}}},
-	{"github.com/go-delve/delve/cmd/dlv", "", false, nil},
-}
-
-// pickVersion returns the version to install based on the supported
-// version list.
-func pickVersion(goMinorVersion int, versions []finalVersion, defaultVersion string) string {
-	for _, v := range versions {
-		if goMinorVersion <= v.goMinorVersion {
-			return v.version
-		}
-	}
-	return defaultVersion
+	{"golang.org/x/tools/gopls", "", true, ""},
+	{"github.com/cweill/gotests/gotests", "", false, ""},
+	{"github.com/haya14busa/goplay/cmd/goplay", "", false, ""},
+	{"honnef.co/go/tools/cmd/staticcheck", "", false, ""},
+	// For regression test: golang/vscode-go#3511
+	{"github.com/golangci/golangci-lint/v2/cmd/golangci-lint", "golangci-lint-v2", false, "v2.12.2"},
+	{"github.com/go-delve/delve/cmd/dlv", "", false, ""},
 }
 
 func main() {
@@ -66,16 +45,13 @@
 	if err != nil {
 		exitf("failed to find go version: %v", err)
 	}
-	if ver < 21 {
-		exitf("unsupported go version: 1.%v", ver)
-	}
 	fmt.Printf("installing tools for go1.%d...\n", ver)
 
 	bin, err := goBin()
 	if err != nil {
 		exitf("failed to determine go tool installation directory: %v", err)
 	}
-	err = installTools(bin, ver)
+	err = installTools(bin)
 	if err != nil {
 		exitf("failed to install tools: %v", err)
 	}
@@ -129,13 +105,16 @@
 	return filepath.Join(gopaths[0], "bin"), nil
 }
 
-func installTools(binDir string, goMinorVersion int) error {
+func installTools(binDir string) error {
 	installCmd := "install"
 
 	// For tools installation, ensure GOTOOLCHAIN=auto.
 	env := append(os.Environ(), "GO111MODULE=on", "GOTOOLCHAIN=auto")
 	for _, tool := range tools {
-		ver := pickVersion(goMinorVersion, tool.versions, pickLatest(tool.path, tool.preferPreview))
+		ver := tool.version
+		if ver == "" {
+			ver = pickLatest(tool.path, tool.preferPreview)
+		}
 		path := tool.path + "@" + ver
 		cmd := exec.Command("go", installCmd, path)
 		cmd.Env = env
diff --git a/extension/tools/installtools/main_test.go b/extension/tools/installtools/main_test.go
deleted file mode 100644
index d8b3d8d..0000000
--- a/extension/tools/installtools/main_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2021 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Binary installtools is a helper that installs Go tools extension tests depend on.
-package main
-
-import "testing"
-
-func Test_pickVersion(t *testing.T) {
-	tests := []struct {
-		name     string
-		versions []finalVersion
-		want     map[int]string
-	}{
-		{
-			name:     "nil",
-			versions: nil,
-			want:     map[int]string{15: "latest", 16: "latest", 17: "latest", 18: "latest"},
-		},
-		{
-			name: "one_entry",
-			versions: []finalVersion{
-				{16, "v0.2.2"},
-			},
-			want: map[int]string{15: "v0.2.2", 16: "v0.2.2", 17: "latest", 18: "latest"},
-		},
-		{
-			name: "two_entries",
-			versions: []finalVersion{
-				{16, "v0.2.2"},
-				{17, "v0.3.0"},
-			},
-			want: map[int]string{15: "v0.2.2", 16: "v0.2.2", 17: "v0.3.0", 18: "latest"},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			for goMinorVersion, want := range tt.want {
-				if got := pickVersion(goMinorVersion, tt.versions, "latest"); got != want {
-					t.Errorf("pickVersion(go 1.%v) = %v, want %v", goMinorVersion, got, want)
-				}
-			}
-		})
-	}
-}