Merge pull request #2391 from acamq/feature/lsp-directories

feat(lsp): add directory support to lsp_diagnostics via extension param
This commit is contained in:
acamq
2026-03-08 19:51:39 -06:00
committed by GitHub
8 changed files with 260 additions and 14 deletions
+3 -3
View File
@@ -184,7 +184,7 @@ task(
After EVERY delegation, complete ALL of these steps — no shortcuts:
#### A. Automated Verification
1. \`lsp_diagnostics(filePath=".")\` → ZERO errors at project level
1. 'lsp_diagnostics(filePath=".", extension=".ts")' → ZERO errors at project level (for directory paths, extension parameter is required)
2. \`bun run build\` or \`bun run typecheck\` → exit code 0
3. \`bun test\` → ALL tests pass
@@ -346,7 +346,7 @@ You are the QA gate. Subagents lie. Verify EVERYTHING.
**After each delegation — BOTH automated AND manual verification are MANDATORY:**
1. \`lsp_diagnostics\` at PROJECT level → ZERO errors
1. 'lsp_diagnostics(filePath=".", extension=".ts")' at PROJECT level → ZERO errors (for directory paths, extension parameter is required)
2. Run build command → exit 0
3. Run test suite → ALL pass
4. **\`Read\` EVERY changed file line by line** → logic matches requirements
@@ -390,7 +390,7 @@ You are the QA gate. Subagents lie. Verify EVERYTHING.
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics after delegation
- Skip project-level lsp_diagnostics after delegation (use 'filePath=".", extension=".ts"' for TypeScript projects)
- Batch multiple tasks in one delegation
- Start fresh session for failures/follow-ups - use \`resume\` instead
+1 -1
View File
@@ -361,7 +361,7 @@ Subagents CLAIM "done" when:
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Skip project-level lsp_diagnostics (use 'filePath=".", extension=".ts"' for TypeScript projects)
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
+2 -2
View File
@@ -55,7 +55,7 @@ Implementation tasks are the means. Final Wave approval is the goal.
- Verification (use Bash for tests/build)
- Parallelize independent tool calls when possible.
- After ANY delegation, verify with your own tool calls:
1. \`lsp_diagnostics\` at project level
1. 'lsp_diagnostics(filePath=".", extension=".ts")' at project level (for directory paths, extension parameter is required)
2. \`Bash\` for build/test commands
3. \`Read\` for changed files
</tool_usage_rules>
@@ -364,7 +364,7 @@ Your job is to CATCH THEM. Assume every claim is false until YOU personally veri
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Skip project-level lsp_diagnostics (use 'filePath=".", extension=".ts"' for TypeScript projects)
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
+1
View File
@@ -1,6 +1,7 @@
export const DEFAULT_MAX_REFERENCES = 200
export const DEFAULT_MAX_SYMBOLS = 200
export const DEFAULT_MAX_DIAGNOSTICS = 200
export const DEFAULT_MAX_DIRECTORY_FILES = 50
export { SYMBOL_KIND_MAP, SEVERITY_MAP, EXT_TO_LANG } from "./language-mappings"
export { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions"
+23 -2
View File
@@ -1,21 +1,42 @@
import { resolve } from "path"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { DEFAULT_MAX_DIAGNOSTICS } from "./constants"
import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
import { withLspClient } from "./lsp-client-wrapper"
import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper"
import type { Diagnostic } from "./types"
export const lsp_diagnostics: ToolDefinition = tool({
description: "Get errors, warnings, hints from language server BEFORE running build.",
description:
'Get errors, warnings, hints from language server BEFORE running build. For directories, provide \'extension\' parameter (e.g., extension=".ts").',
args: {
filePath: tool.schema.string(),
severity: tool.schema
.enum(["error", "warning", "information", "hint", "all"])
.optional()
.describe("Filter by severity level"),
extension: tool.schema
.string()
.optional()
.describe("Required if filePath is a directory. E.g., '.ts', '.py', '.go'"),
},
execute: async (args, _context) => {
try {
const absPath = resolve(args.filePath)
if (isDirectoryPath(absPath)) {
if (!args.extension) {
throw new Error(
`Directory path requires 'extension' parameter.\n\n` +
`Example: lsp_diagnostics(filePath="src", extension=".ts")\n\n` +
`Supported extensions: .ts, .tsx, .js, .py, .go, etc.`
)
}
return await aggregateDiagnosticsForDirectory(absPath, args.extension, args.severity)
}
const result = await withLspClient(args.filePath, async (client) => {
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
})
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import os from "os"
import { isDirectoryPath } from "./lsp-client-wrapper"
import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics"
describe("directory diagnostics", () => {
describe("isDirectoryPath", () => {
it("returns true for existing directory", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-isdir-"))
try {
expect(isDirectoryPath(tmp)).toBe(true)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it("returns false for existing file", () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-isdir-file-"))
try {
const file = join(tmp, "test.txt")
writeFileSync(file, "content")
expect(isDirectoryPath(file)).toBe(false)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it("returns false for non-existent path", () => {
const nonExistent = join(os.tmpdir(), "omo-nonexistent-" + Date.now())
expect(isDirectoryPath(nonExistent)).toBe(false)
})
})
describe("aggregateDiagnosticsForDirectory", () => {
it("throws error when extension does not start with dot", async () => {
const tmp = mkdtempSync(join(os.tmpdir(), "omo-aggr-ext-"))
try {
await expect(aggregateDiagnosticsForDirectory(tmp, "ts")).rejects.toThrow(
'Extension must start with a dot (e.g., ".ts", not "ts")'
)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it("throws error when directory does not exist", async () => {
const nonExistent = join(os.tmpdir(), "omo-nonexistent-dir-" + Date.now())
await expect(aggregateDiagnosticsForDirectory(nonExistent, ".ts")).rejects.toThrow(
"Directory does not exist"
)
})
})
})
+153
View File
@@ -0,0 +1,153 @@
import { existsSync, lstatSync, readdirSync, type Stats } from "fs"
import { extname, join, resolve } from "path"
import { findServerForExtension } from "./config"
import { findWorkspaceRoot, formatServerLookupError } from "./lsp-client-wrapper"
import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters"
import { LSPClient } from "./lsp-client"
import { lspManager } from "./lsp-server"
import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants"
import type { Diagnostic } from "./types"
const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"])
function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] {
const files: string[] = []
function walk(currentDir: string): void {
if (files.length >= maxFiles) return
let entries: string[] = []
try {
entries = readdirSync(currentDir)
} catch {
return
}
for (const entry of entries) {
if (files.length >= maxFiles) return
const fullPath = join(currentDir, entry)
let stat: Stats | undefined
try {
stat = lstatSync(fullPath)
} catch {
continue
}
if (!stat || stat.isSymbolicLink()) {
continue
}
if (stat.isDirectory()) {
if (!SKIP_DIRECTORIES.has(entry)) {
walk(fullPath)
}
} else if (stat.isFile()) {
if (extname(fullPath) === extension) {
files.push(fullPath)
}
}
}
}
walk(dir)
return files
}
export async function aggregateDiagnosticsForDirectory(
directory: string,
extension: string,
severity?: "error" | "warning" | "information" | "hint" | "all",
maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES
): Promise<string> {
if (!extension.startsWith(".")) {
throw new Error(
`Extension must start with a dot (e.g., ".ts", not "${extension}"). ` +
`Use ".${extension}" instead.`
)
}
const absDir = resolve(directory)
if (!existsSync(absDir)) {
throw new Error(`Directory does not exist: ${absDir}`)
}
const serverResult = findServerForExtension(extension)
if (serverResult.status !== "found") {
throw new Error(formatServerLookupError(serverResult))
}
const server = serverResult.server
const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1)
const wasCapped = allFiles.length > maxFiles
const filesToProcess = allFiles.slice(0, maxFiles)
if (filesToProcess.length === 0) {
return [
`Directory: ${absDir}`,
`Extension: ${extension}`,
`Files scanned: 0`,
`No files found with extension "${extension}".`,
].join("\n")
}
const root = findWorkspaceRoot(absDir)
const allDiagnostics: Diagnostic[] = []
const fileErrors: { file: string; error: string }[] = []
let client: LSPClient
try {
client = await lspManager.getClient(root, server)
for (const file of filesToProcess) {
try {
const result = await client.diagnostics(file)
const filtered = filterDiagnosticsBySeverity(result.items, severity)
allDiagnostics.push(...filtered)
} catch (e) {
fileErrors.push({
file,
error: e instanceof Error ? e.message : String(e),
})
}
}
} finally {
lspManager.releaseClient(root, server.id)
}
const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS)
const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS
const lines: string[] = [
`Directory: ${absDir}`,
`Extension: ${extension}`,
`Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`,
`Files with errors: ${fileErrors.length}`,
`Total diagnostics: ${allDiagnostics.length}`,
]
if (fileErrors.length > 0) {
lines.push("", "File processing errors:")
for (const { file, error } of fileErrors) {
lines.push(` ${file}: ${error}`)
}
}
if (displayDiagnostics.length > 0) {
lines.push("")
for (const diag of displayDiagnostics) {
lines.push(formatDiagnostic(diag))
}
if (wasDiagCapped) {
lines.push(
"",
`... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)`
)
}
}
return lines.join("\n")
}
+21 -6
View File
@@ -1,15 +1,26 @@
import { extname, resolve } from "path"
import { fileURLToPath } from "node:url"
import { existsSync } from "fs"
import { existsSync, statSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
import type { ServerLookupResult } from "./types"
export function isDirectoryPath(filePath: string): boolean {
if (!existsSync(filePath)) {
return false
}
return statSync(filePath).isDirectory()
}
export function uriToPath(uri: string): string {
return fileURLToPath(uri)
}
export function findWorkspaceRoot(filePath: string): string {
let dir = resolve(filePath)
if (!existsSync(dir) || !require("fs").statSync(dir).isDirectory()) {
if (!existsSync(dir) || !isDirectoryPath(dir)) {
dir = require("path").dirname(dir)
}
@@ -29,10 +40,6 @@ export function findWorkspaceRoot(filePath: string): string {
return require("path").dirname(resolve(filePath))
}
export function uriToPath(uri: string): string {
return fileURLToPath(uri)
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result
@@ -70,6 +77,14 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
const absPath = resolve(filePath)
if (isDirectoryPath(absPath)) {
throw new Error(
`Directory paths are not supported by this LSP tool. ` +
`Use lsp_diagnostics with the 'extension' parameter for directory diagnostics.`
)
}
const ext = extname(absPath)
const result = findServerForExtension(ext)