diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d84bee2..d89a2c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,12 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm + - uses: actions/setup-go@v7 + with: + go-version: '1.27.3' + cache: false + - run: GOBIN="$RUNNER_TEMP/gopls-bin" go install golang.org/x/tools/gopls@v0.24.0 + - run: echo "$RUNNER_TEMP/gopls-bin" >> "$GITHUB_PATH" - run: npm ci - run: npm test env: diff --git a/package-lock.json b/package-lock.json index a1e5532..149bee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "skills": "1.7.2", "ssri": "14.0.0", "string-width": "8.3.0", + "vscode-languageserver-protocol": "3.18.5", "yaml": "^2.9.1" }, "bin": { @@ -3603,6 +3604,31 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/vscode-jsonrpc": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-10.0.0.tgz", + "integrity": "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==", + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.5.tgz", + "integrity": "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "10.0.0", + "vscode-languageserver-types": "3.18.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.5.tgz", + "integrity": "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==", + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", diff --git a/package.json b/package.json index adfd898..3237e31 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "skills": "1.7.2", "ssri": "14.0.0", "string-width": "8.3.0", + "vscode-languageserver-protocol": "3.18.5", "yaml": "^2.9.1" }, "devDependencies": { diff --git a/skills/coderuler-new-rule/reference.md b/skills/coderuler-new-rule/reference.md index a0ac979..dc84a65 100644 --- a/skills/coderuler-new-rule/reference.md +++ b/skills/coderuler-new-rule/reference.md @@ -353,9 +353,43 @@ export default class Local extends Rule { } ``` - Use `trace` for decision evidence visible during inspection; keep normal execution free of ad-hoc console output. AI/regex projection callbacks receive only `ProjectContext`, not the full check context. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. LSP use is optional; install Go and `gopls` only if a rule calls these helpers. Declaration lookup is part of the cross-backend API, but gopls v0.24.0 does not support it, so that query rejects. + +Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries use the enclosing named function or method when the column is omitted. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. + +Caller and callee results are a static call graph. They can omit dynamic calls and do not load every possible package consumer. Use them as the server's known call relationships, not as a complete runtime graph. + +Call graph helpers from an async custom check: + +```ts +import { Rule, pass, scopes, violation } from '@alex-dbs/coderuler'; +import type { Evaluator } from '@alex-dbs/coderuler'; + +export default class CallGraphCheck extends Rule { + files = ['**/*.go']; + scope = scopes.astGrep({ + language: 'go', + rule: { any: [{ kind: 'function_declaration' }, { kind: 'method_declaration' }] }, + }); + + check: Evaluator = async (subject, context) => { + if (subject.kind !== 'scope' || subject.lines === undefined) return pass(); + const target = { filepath: subject.filepath, line: subject.lines.start }; + const [callers, callees] = await Promise.all([ + context.goLsp.callers(target, { depth: 2 }), + context.goLsp.callees(target, { depth: 2, includeDependencies: true }), + ]); + return callers.length + callees.length === 0 + ? pass() + : violation('Review callers and callees'); + }; +} +``` + +The example assumes `pass` and `violation` are imported from the SDK. + ## Regex: matching means passing `regex(options: RegexOptions): Evaluator` takes `{ readonly pattern: RegExp; readonly violationMessage?: string; readonly input?: (subject: ScopeSubject, context: ProjectContext) => Awaitable }`. diff --git a/src/adapter/filesystem/rule-test-lsp.ts b/src/adapter/filesystem/rule-test-lsp.ts new file mode 100644 index 0000000..a77ef5d --- /dev/null +++ b/src/adapter/filesystem/rule-test-lsp.ts @@ -0,0 +1,189 @@ +import type { MessagePort } from 'node:worker_threads'; + +import { createRunLsp } from '../../app/lsp.js'; +import type { LspTools } from '../../app/rule.js'; +import type { LspBackend, LspBackendFactory, Project } from '../../app/ports.js'; + +type LspMethod = keyof LspTools; +type RpcMethod = LspMethod | 'close'; +type RpcCall = { + readonly id: number; + readonly method: Method; + readonly args: Parameters; +}; +type RpcRequest = { [Method in LspMethod]: RpcCall }[LspMethod] + | { readonly id: number; readonly method: 'close'; readonly args: [] }; +type RpcResponse = + | { readonly id: number; readonly result: unknown } + | { readonly id: number; readonly error: string }; +type RpcArgs = Method extends LspMethod + ? Parameters + : []; +type RpcResult = Method extends LspMethod + ? Awaited> + : void; + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function send(port: MessagePort, response: RpcResponse): void { + try { + port.postMessage(response); + } catch { + // A peer can close its port while an operation is settling. + } +} + +export function createRuleTestLspHost( + port: MessagePort, + project: Project, + createBackend?: LspBackendFactory, +): { close(): Promise } { + const facade = createRunLsp(project, createBackend); + let closed = false; + let closePromise: Promise | undefined; + + const onRequest = (value: unknown): void => { + if (value === null || typeof value !== 'object') return; + const candidate = value as { readonly id?: unknown; readonly args?: unknown }; + if (!Number.isSafeInteger(candidate.id) || !Array.isArray(candidate.args)) return; + const request = value as RpcRequest; + void (async () => { + try { + let result: unknown; + switch (request.method) { + case 'definitions': result = await facade.tools.definitions(...request.args); break; + case 'declarations': result = await facade.tools.declarations(...request.args); break; + case 'typeDefinitions': result = await facade.tools.typeDefinitions(...request.args); break; + case 'implementations': result = await facade.tools.implementations(...request.args); break; + case 'references': result = await facade.tools.references(...request.args); break; + case 'hover': result = await facade.tools.hover(...request.args); break; + case 'documentSymbols': result = await facade.tools.documentSymbols(...request.args); break; + case 'workspaceSymbols': result = await facade.tools.workspaceSymbols(...request.args); break; + case 'callers': result = await facade.tools.callers(...request.args); break; + case 'callees': result = await facade.tools.callees(...request.args); break; + case 'close': await facade.close(); result = undefined; break; + default: throw new Error('Unsupported rule-test LSP method'); + } + send(port, { id: request.id, result }); + } catch (error) { + send(port, { id: request.id, error: describe(error) }); + } + })(); + }; + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closed = true; + port.off('message', onRequest); + closePromise = (async () => { + try { + await facade.close(); + } finally { + port.close(); + } + })(); + return closePromise; + }; + const onPortClose = (): void => { + if (!closed) void close().catch(() => undefined); + }; + + port.on('message', onRequest); + port.once('close', onPortClose); + port.start(); + return { close }; +} + +export function createRuleTestLspBackend(port: MessagePort, signal: AbortSignal): LspBackend { + let nextId = 0; + let portClosed = false; + let signalAborted = false; + let closePromise: Promise | undefined; + const pending = new Map(); + + const rejectPending = (error: Error): void => { + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; + const onResponse = (value: unknown): void => { + if (value === null || typeof value !== 'object') return; + const candidate = value as { readonly id?: unknown }; + if (!Number.isSafeInteger(candidate.id)) return; + const response = value as RpcResponse; + const request = pending.get(response.id); + if (request === undefined) return; + pending.delete(response.id); + if ('error' in response) request.reject(new Error(response.error)); + else request.resolve(response.result); + }; + const onPortClose = (): void => { + if (portClosed) return; + portClosed = true; + rejectPending(new Error('Rule test LSP host closed')); + }; + const onAbort = (): void => { + signalAborted = true; + rejectPending(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted')); + }; + + port.on('message', onResponse); + port.once('close', onPortClose); + port.start(); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + + const request = ( + method: Method, + args: RpcArgs, + allowAborted = false, + ): Promise> => { + if (portClosed) return Promise.reject(new Error('Rule test LSP host closed')); + if (signalAborted && !allowAborted) { + return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted')); + } + const id = nextId++; + const deferred = Promise.withResolvers>(); + pending.set(id, { + resolve: value => deferred.resolve(value as RpcResult), + reject: deferred.reject, + }); + try { + port.postMessage({ id, method, args } as RpcRequest); + } catch (error) { + pending.delete(id); + deferred.reject(error instanceof Error ? error : new Error(describe(error))); + } + return deferred.promise; + }; + + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closePromise = (async () => { + try { + await request('close', [], true); + } finally { + signal.removeEventListener('abort', onAbort); + port.off('message', onResponse); + port.off('close', onPortClose); + port.close(); + onPortClose(); + } + })(); + return closePromise; + }; + + return { + definitions: (target, options) => request('definitions', [target, options]), + declarations: (target, options) => request('declarations', [target, options]), + typeDefinitions: (target, options) => request('typeDefinitions', [target, options]), + implementations: (target, options) => request('implementations', [target, options]), + references: (target, options) => request('references', [target, options]), + hover: (target, options) => request('hover', [target, options]), + documentSymbols: (filepath, options) => request('documentSymbols', [filepath, options]), + workspaceSymbols: (query, options) => request('workspaceSymbols', [query, options]), + callers: (target, options) => request('callers', [target, options]), + callees: (target, options) => request('callees', [target, options]), + close, + }; +} diff --git a/src/adapter/filesystem/rule-test-worker.ts b/src/adapter/filesystem/rule-test-worker.ts index 296baa7..13b4333 100644 --- a/src/adapter/filesystem/rule-test-worker.ts +++ b/src/adapter/filesystem/rule-test-worker.ts @@ -1,16 +1,18 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, resolve, sep, win32 } from 'node:path'; -import { parentPort } from 'node:worker_threads'; +import { parentPort, type MessagePort } from 'node:worker_threads'; import { executeRules } from '../../app/execute.js'; import type { RuleTestCase } from '../../app/rule-tests.js'; import { createDecisionResolver } from '../../infra/settings.js'; -import { createProject } from './project.js'; import type { PreparedRuleDependencies } from './dependencies.js'; +import { createRuleTestLspBackend } from './rule-test-lsp.js'; +import { createProject } from './project.js'; import { createRuleLoader } from './rules.js'; export interface RuleTestWorkerRequest { readonly id: number; + readonly lspPort: MessagePort; readonly root: string; readonly ruleFile: string; readonly files: RuleTestCase['files']; @@ -35,35 +37,41 @@ function fixturePath(root: string, filepath: string): string { return target; } -async function runCase({ root, ruleFile, files, ai, dependencies, includeDisabled }: RuleTestWorkerRequest): Promise { - for (const [filepath, source] of Object.entries(files)) { - const target = fixturePath(root, filepath); - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8'); - } - const rules = await createRuleLoader( - dependencies === undefined ? {} : { preparedDependencies: dependencies }, - ).load({ root, explicit: ruleFile, invocationCwd: root }); - if (rules.length !== 1) throw new Error('Expected exactly one freshly loaded rule'); - if (includeDisabled === true) rules[0]!.rule.enabled = true; - const reports = await executeRules(rules, { - project: createProject(root, { excludeFiles: [ruleFile] }), - isTarget: () => true, - concurrency: 1, - resolveDecision: ai ? createDecisionResolver(process.env) : () => { - throw new Error('Inline rule tests must not call the decision backend'); - }, - }); - const report = reports[0]; - if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report'); - let aiScores: number[] | undefined; - if (ai) { - for (const evaluation of report.evaluations) { - const score = evaluation.result.probability; - if (score !== undefined) (aiScores ??= []).push(score); +async function runCase(request: RuleTestWorkerRequest): Promise { + const { lspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request; + try { + for (const [filepath, source] of Object.entries(files)) { + const target = fixturePath(root, filepath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8'); + } + const rules = await createRuleLoader( + dependencies === undefined ? {} : { preparedDependencies: dependencies }, + ).load({ root, explicit: ruleFile, invocationCwd: root }); + if (rules.length !== 1) throw new Error('Expected exactly one freshly loaded rule'); + if (includeDisabled === true) rules[0]!.rule.enabled = true; + const reports = await executeRules(rules, { + project: createProject(root, { excludeFiles: [ruleFile] }), + isTarget: () => true, + concurrency: 1, + resolveDecision: ai ? createDecisionResolver(process.env) : () => { + throw new Error('Inline rule tests must not call the decision backend'); + }, + createLspBackend: (_project, signal) => createRuleTestLspBackend(lspPort, signal), + }); + const report = reports[0]; + if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report'); + let aiScores: number[] | undefined; + if (ai) { + for (const evaluation of report.evaluations) { + const score = evaluation.result.probability; + if (score !== undefined) (aiScores ??= []).push(score); + } } + return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) }; + } finally { + lspPort.close(); } - return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) }; } const port = parentPort; diff --git a/src/adapter/filesystem/rule-tests.ts b/src/adapter/filesystem/rule-tests.ts index 74725dd..ae58102 100644 --- a/src/adapter/filesystem/rule-tests.ts +++ b/src/adapter/filesystem/rule-tests.ts @@ -2,12 +2,14 @@ import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { Worker } from 'node:worker_threads'; +import { MessageChannel, Worker } from 'node:worker_threads'; -import type { LoadedRule } from '../../app/ports.js'; +import type { LoadedRule, LspBackendFactory } from '../../app/ports.js'; import type { RuleTestCase, RuleTestResult } from '../../app/rule-tests.js'; import { isRecord, validateJsonValue } from '../../app/validate.js'; import type { RuleTestWorkerMessage, RuleTestWorkerOutcome, RuleTestWorkerRequest } from './rule-test-worker.js'; +import { createRuleTestLspHost } from './rule-test-lsp.js'; +import { createProject } from './project.js'; import { getRuleDependencies, registerRuleDependencies } from './rule-resolution.js'; // A JavaScript entry lets inherited tsx hooks resolve the source worker's .js import. @@ -75,6 +77,13 @@ function addTestGroup( type TestActual = RuleTestResult['actual']; +interface ActiveRuleTestLsp { + readonly channel: MessageChannel; + readonly host: { close(): Promise }; + transferred: boolean; + closePromise?: Promise; +} + class RuleTestWorkerSession { private readonly worker = new Worker(WORKER_ENTRY); private nextId = 0; @@ -83,6 +92,7 @@ class RuleTestWorkerSession { private onFailure: ((error: Error) => void) | undefined; private terminating = false; private termination: Promise | undefined; + private activeLsp: ActiveRuleTestLsp | undefined; private readonly onError = (error: Error) => { if (this.terminating) return; @@ -106,16 +116,33 @@ class RuleTestWorkerSession { if (this.failure !== undefined) throw this.failure; } + private closeLsp(active = this.activeLsp): Promise { + if (active === undefined) return Promise.resolve(); + if (active.closePromise !== undefined) return active.closePromise; + active.closePromise = (async () => { + try { + await active.host.close(); + } finally { + active.channel.port1.close(); + if (!active.transferred) active.channel.port2.close(); + if (this.activeLsp === active) this.activeLsp = undefined; + } + })(); + return active.closePromise; + } + async run( - request: Omit, + request: Omit, deadline: number, onFailure: (error: Error) => void, + createBackend?: LspBackendFactory, ): Promise { this.onFailure = onFailure; this.assertHealthy(); const id = this.nextId++; const result = Promise.withResolvers(); let timer: NodeJS.Timeout | undefined; + let activeLsp: ActiveRuleTestLsp | undefined; const expire = () => { const remaining = deadline - performance.now(); if (remaining <= 0) result.resolve('timeout'); @@ -131,17 +158,47 @@ class RuleTestWorkerSession { if (performance.now() >= deadline) result.resolve('timeout'); else result.reject(error); }; + let operationFailed = false; this.worker.on('message', onMessage); try { expire(); if (performance.now() < deadline) { - this.worker.postMessage({ ...request, id } satisfies RuleTestWorkerRequest); + const channel = new MessageChannel(); + try { + const host = createRuleTestLspHost( + channel.port1, + createProject(request.root, { excludeFiles: [request.ruleFile] }), + createBackend, + ); + activeLsp = { channel, host, transferred: false }; + this.activeLsp = activeLsp; + this.worker.postMessage( + { ...request, id, lspPort: channel.port2 } satisfies RuleTestWorkerRequest, + [channel.port2], + ); + activeLsp.transferred = true; + } catch (error) { + if (activeLsp === undefined) { + channel.port1.close(); + channel.port2.close(); + } + throw error; + } } return await result.promise; + } catch (error) { + operationFailed = true; + throw error; } finally { clearTimeout(timer); this.worker.off('message', onMessage); this.rejectPending = undefined; + try { + await this.closeLsp(activeLsp); + } catch (error) { + if (!operationFailed) throw error; + if (activeLsp !== undefined) activeLsp.closePromise = Promise.resolve(); + } } } @@ -149,11 +206,26 @@ class RuleTestWorkerSession { if (this.termination === undefined) { this.terminating = true; this.rejectPending?.(new Error('Rule test worker stopped')); - this.termination = this.worker.terminate(); + this.termination = (async () => { + let closeFailed = false; + let closeError: unknown; + try { + await this.closeLsp(); + } catch (error) { + closeFailed = true; + closeError = error; + } + const code = await this.worker.terminate(); + if (closeFailed) throw closeError; + return code; + })(); + } + try { + await this.termination; + } finally { + this.worker.off('error', this.onError); + this.worker.off('exit', this.onExit); } - await this.termination; - this.worker.off('error', this.onError); - this.worker.off('exit', this.onExit); } } @@ -176,6 +248,7 @@ async function runCase( session: RuleTestSession, onFailure: (error: unknown) => void, includeDisabled: boolean, + createLspBackend?: LspBackendFactory, ): Promise { const started = performance.now(); const deadline = started + timeoutMs; @@ -202,7 +275,7 @@ async function runCase( root, ruleFile: entry.ruleFile, files: example.files, ai, ...(includeDisabled ? { includeDisabled: true } : {}), ...(dependencies === undefined ? {} : { dependencies }), - }, deadline, failCase); + }, deadline, failCase, createLspBackend); if (typeof outcome === 'string') actual = outcome; else { actual = outcome.actual; @@ -241,6 +314,7 @@ export async function runRuleTests( concurrency = 1, includeAI = false, includeDisabled = false, + createLspBackend?: LspBackendFactory, ): Promise { if (!Number.isSafeInteger(concurrency) || concurrency < 1) { throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`); @@ -300,7 +374,9 @@ export async function runRuleTests( } const group = groups[groupIndex]!; const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`); - completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled); + completed[index] = await runCase( + rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, + ); if (failureObserved) return; while (completed[nextResult] !== undefined) { const result = completed[nextResult]!; @@ -330,6 +406,10 @@ export async function runRuleTests( return results; } finally { await cancellation; - await Promise.all(sessions.map(stopSession)); + try { + await Promise.all(sessions.map(stopSession)); + } catch (error) { + if (!failureObserved) throw error; + } } } diff --git a/src/adapter/lsp/backend.ts b/src/adapter/lsp/backend.ts new file mode 100644 index 0000000..b917571 --- /dev/null +++ b/src/adapter/lsp/backend.ts @@ -0,0 +1,886 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse, type SgNode, type SgRoot } from '@ast-grep/napi'; +import { + ApplyWorkspaceEditRequest, + CallHierarchyIncomingCallsRequest, + CallHierarchyOutgoingCallsRequest, + CallHierarchyPrepareRequest, + DeclarationRequest, + DefinitionRequest, + DidOpenTextDocumentNotification, + DocumentSymbolRequest, + ExitNotification, + HoverRequest, + ImplementationRequest, + InitializeRequest, + InitializedNotification, + ReferencesRequest, + ShowMessageRequest, + ShutdownRequest, + SymbolKind, + TypeDefinitionRequest, + WorkspaceSymbolRequest, + type ProtocolRequestType, + type RequestParam, + type ProtocolConnection, + type CallHierarchyItem, + type CallHierarchyIncomingCall, + type CallHierarchyOutgoingCall, + type DocumentSymbol, + type Hover, + type Location, + type LocationLink, + type Position, + type Range, + type ServerCapabilities, + type SymbolInformation, + type WorkspaceFolder, + type WorkspaceSymbol, +} from 'vscode-languageserver-protocol'; +import { createProtocolConnection } from 'vscode-languageserver-protocol/node'; +import type { LspCall, LspCallOptions, LspOptions, LspReferenceOptions, LspTarget, LspTools } from '../../app/rule.js'; +import type { LspBackend, Project } from '../../app/ports.js'; +import { resolveAstGrepLanguage } from '../scope/astgrep-languages.js'; +import { textLineSpan, type LineSpan } from '../scope/lines.js'; + +interface DeclarationResult { + readonly source: string; + readonly lines: LineSpan; + readonly range: Range; +} + +export interface LspServerOptions { + readonly command: string; + readonly args?: readonly string[]; + readonly languageId: string; + readonly env?: NodeJS.ProcessEnv; + readonly requestTimeoutMs?: number; +} + +interface TextIndex { + readonly lineStarts: readonly number[]; + readonly lineEnds: readonly number[]; + readonly lineCount: number; + position(line: number, column: number): Position; + offset(position: Position): number; +} + +interface Document { + readonly uri: string; + readonly text: string; + readonly index: TextIndex; +} + +interface SymbolDeclaration { + readonly name: string; + readonly kind: number; + readonly range: Range; + readonly selectionRange: Range; + readonly hasFullRange: boolean; +} + +interface GraphNode { + readonly item: CallHierarchyItem; + readonly depth: number; +} + +const DEFAULT_OPTIONS: LspServerOptions = { + command: 'gopls', + args: ['serve'], + languageId: 'go', +}; +const MAX_STDERR_BYTES = 8 * 1024; +const SHUTDOWN_GRACE_MS = 2_000; +const AST_DECLARATION_KINDS: Readonly> = { + function_declaration: true, + method_declaration: true, + var_declaration: true, + const_declaration: true, + type_declaration: true, +}; + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createTextIndex(text: string): TextIndex { + const lineStarts = [0]; + const lineEnds: number[] = []; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code === 10 || code === 13) { + lineEnds.push(index); + if (code === 13 && text.charCodeAt(index + 1) === 10) index += 1; + lineStarts.push(index + 1); + } + } + lineEnds.push(text.length); + + const offset = (position: Position): number => { + if (!Number.isSafeInteger(position.line) || position.line < 0 || position.line >= lineStarts.length) { + throw new Error(`LSP position line ${position.line + 1} is outside the document`); + } + if (!Number.isSafeInteger(position.character) || position.character < 0) { + throw new Error('LSP position character must be a nonnegative safe integer'); + } + const start = lineStarts[position.line]!; + const end = lineEnds[position.line]!; + if (position.character > end - start) { + throw new Error(`LSP position column ${position.character + 1} is outside line ${position.line + 1}`); + } + return start + position.character; + }; + + return { + lineStarts, + lineEnds, + lineCount: lineStarts.length, + position(line: number, column: number): Position { + if (!Number.isSafeInteger(line) || line < 1) { + throw new Error('LSP line must be a positive safe integer'); + } + if (!Number.isSafeInteger(column) || column < 1) { + throw new Error('LSP column must be a positive safe integer'); + } + const zeroLine = line - 1; + if (zeroLine >= lineStarts.length) throw new Error(`LSP line ${line} is outside the document`); + const character = column - 1; + if (character > lineEnds[zeroLine]! - lineStarts[zeroLine]!) { + throw new Error(`LSP column ${column} is outside line ${line}`); + } + return { line: zeroLine, character }; + }, + offset, + }; +} + +function isWithinRoot(root: string, filepath: string): boolean { + const relativePath = relative(root, filepath); + if (relativePath === '') return true; + const firstSegment = relativePath.split(sep)[0]; + return firstSegment !== '..' && !isAbsolute(relativePath); +} + +function comparePosition(left: Position, right: Position): number { + return left.line - right.line || left.character - right.character; +} + +function compareRanges(left: Range, right: Range): number { + return comparePosition(left.start, right.start) || comparePosition(left.end, right.end); +} + +function rangeContains(outer: Range, inner: Range): boolean { + return comparePosition(outer.start, inner.start) <= 0 && comparePosition(outer.end, inner.end) >= 0; +} + +function rangeStrictlyContains(outer: Range, inner: Range): boolean { + return rangeContains(outer, inner) && compareRanges(outer, inner) !== 0; +} + +function sameRange(left: Range, right: Range): boolean { + return compareRanges(left, right) === 0; +} + +function normalizeLocations(result: unknown): readonly Location[] { + if (result === null || result === undefined) return []; + const values: readonly unknown[] = Array.isArray(result) ? result : [result]; + const normalized: Location[] = []; + for (const value of values) { + if (typeof value !== 'object' || value === null) continue; + if ('targetUri' in value && 'targetSelectionRange' in value) { + const link = value as LocationLink; + normalized.push({ uri: link.targetUri, range: link.targetSelectionRange }); + } else if ('uri' in value && 'range' in value) { + normalized.push(value as Location); + } + } + return normalized; +} + +function isDocumentSymbol(value: DocumentSymbol | SymbolInformation): value is DocumentSymbol { + return 'selectionRange' in value; +} + +function flattenSymbols( + symbols: readonly DocumentSymbol[] | readonly SymbolInformation[], +): readonly SymbolDeclaration[] { + const declarations: SymbolDeclaration[] = []; + const visit = (symbol: DocumentSymbol): void => { + declarations.push({ + name: symbol.name, + kind: symbol.kind, + range: symbol.range, + selectionRange: symbol.selectionRange, + hasFullRange: true, + }); + for (const child of symbol.children ?? []) visit(child); + }; + for (const symbol of symbols) { + if (isDocumentSymbol(symbol)) visit(symbol); + else declarations.push({ + name: symbol.name, + kind: symbol.kind, + range: symbol.location.range, + selectionRange: symbol.location.range, + hasFullRange: false, + }); + } + return declarations; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function createError(message: string, cause?: unknown): Error { + return cause === undefined ? new Error(message) : new Error(message, { cause }); +} + +export async function createLspBackend( + project: Project, + signal: AbortSignal, + options?: LspServerOptions, +): Promise { + const settings = options ?? DEFAULT_OPTIONS; + const timeoutMs = settings.requestTimeoutMs ?? 60_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new Error('LSP requestTimeoutMs must be a positive safe integer'); + } + if (signal.aborted) throw createError('LSP startup was aborted', signal.reason); + + const root = resolve(project.root); + const rootUri = pathToFileURL(root).href; + const workspaceFolder: WorkspaceFolder = { uri: rootUri, name: basename(root) }; + const child = spawn(settings.command, [...(settings.args ?? [])], { + cwd: root, + env: settings.env, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stderr = Buffer.alloc(0); + let terminalError: Error | undefined; + let initialized = false; + let closing = false; + let processClosed = false; + let releasePromise: Promise | undefined; + let closePromise: Promise | undefined; + let connection: ProtocolConnection | undefined; + let capabilities: ServerCapabilities | undefined; + const controller = new AbortController(); + const documentPromises = new Map>(); + const symbolPromises = new Map>(); + const astPromises = new Map>(); + + const { promise: processClosedPromise, resolve: resolveProcessClosed } = Promise.withResolvers(); + + const stderrText = (): string => stderr.toString('utf8').trim(); + const withStderr = (error: unknown): Error => { + const message = describe(error); + const detail = stderrText(); + return createError(detail === '' ? message : `${message}\nLSP stderr (latest ${MAX_STDERR_BYTES} bytes):\n${detail}`, error); + }; + const closedError = (): Error => terminalError ?? new Error('LSP session is closed'); + + const cleanup = (graceful: boolean): Promise => { + if (releasePromise !== undefined) return releasePromise; + releasePromise = (async (): Promise => { + if (graceful && initialized && !processClosed && connection !== undefined) { + const shutdown = (async (): Promise => { + try { + await connection!.sendRequest(ShutdownRequest.type); + await connection!.sendNotification(ExitNotification.type); + } catch { + // A bounded process release below handles failed shutdown handshakes. + } + })(); + void shutdown.catch(() => undefined); + } else if (!processClosed) { + child.kill('SIGTERM'); + } + + if (!processClosed) { + const { promise: expired, resolve: resolveExpired } = Promise.withResolvers<'timeout'>(); + const releaseTimer = setTimeout(() => resolveExpired('timeout'), SHUTDOWN_GRACE_MS); + const closed = processClosedPromise.then(() => 'closed' as const); + const result = await Promise.race([closed, expired]); + clearTimeout(releaseTimer); + if (result === 'timeout' && !processClosed) child.kill('SIGKILL'); + if (!processClosed) await processClosedPromise; + } + + connection?.dispose(); + signal.removeEventListener('abort', onRunAbort); + child.removeAllListeners('error'); + child.removeAllListeners('close'); + child.stderr?.removeAllListeners('data'); + })(); + return releasePromise; + }; + + const fail = (error: unknown): void => { + if (terminalError !== undefined || closing) return; + terminalError = withStderr(error); + controller.abort(terminalError); + void cleanup(false).catch(() => undefined); + }; + + const onRunAbort = (): void => { + if (!closing) fail(createError('LSP session was aborted', signal.reason)); + }; + signal.addEventListener('abort', onRunAbort, { once: true }); + if (signal.aborted) onRunAbort(); + + child.stderr?.on('data', (chunk: Buffer | string) => { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stderr = Buffer.concat([stderr, bytes]).subarray(-MAX_STDERR_BYTES); + }); + child.once('error', (error) => { + fail(createError(`LSP process '${settings.command}' failed: ${describe(error)}`, error)); + }); + child.once('close', (code, processSignal) => { + processClosed = true; + resolveProcessClosed(); + if (!closing && terminalError === undefined) { + fail(createError(`LSP process exited unexpectedly (code ${String(code)}, signal ${String(processSignal)})`)); + } + }); + + const bounded = (method: string, operation: () => Promise): Promise => { + if (terminalError !== undefined || closing || controller.signal.aborted) { + return Promise.reject(closedError()); + } + let abortListener: (() => void) | undefined; + const { promise: aborted, reject: rejectAbort } = Promise.withResolvers(); + abortListener = (): void => rejectAbort(closedError()); + controller.signal.addEventListener('abort', abortListener, { once: true }); + + let operationPromise: Promise; + try { + operationPromise = operation(); + } catch (error) { + operationPromise = Promise.reject(error); + } + + const { promise: timeout, reject: rejectTimeout } = Promise.withResolvers(); + const timer = setTimeout(() => { + const error = createError(`LSP request ${method} timed out after ${timeoutMs}ms`); + rejectTimeout(error); + fail(error); + }, timeoutMs); + return Promise.race([operationPromise, aborted, timeout]).finally(() => { + clearTimeout(timer); + if (abortListener !== undefined) controller.signal.removeEventListener('abort', abortListener); + }); + }; + + const ensureAvailable = (): void => { + if (terminalError !== undefined || closing || controller.signal.aborted) throw closedError(); + if (connection === undefined) throw new Error('LSP connection is not initialized'); + }; + + const assertCapability = (method: string, capability: keyof ServerCapabilities): void => { + ensureAvailable(); + if (capabilities?.[capability] === undefined || capabilities[capability] === false) { + throw new Error(`LSP server does not support ${method}`); + } + }; + + const request = ( + type: ProtocolRequestType, + method: string, + params: RequestParam

, + capability?: keyof ServerCapabilities, + ): Promise => { + ensureAvailable(); + if (capability !== undefined) assertCapability(method, capability); + return bounded(method, () => connection!.sendRequest(type, params)); + }; + + const notify = (method: string, operation: () => Promise): Promise => { + ensureAvailable(); + return bounded(method, operation); + }; + + try { + connection = createProtocolConnection(child.stdout!, child.stdin!); + connection.onError((details) => { + const [error, message, code] = details; + fail(createError( + `LSP transport failed${message !== undefined && 'method' in message ? ` for ${message.method}` : ''}${code === undefined ? '' : ` (${code})`}: ${describe(error)}`, + error, + )); + }); + connection.onClose(() => { + if (!closing && terminalError === undefined) fail(new Error('LSP transport closed unexpectedly')); + }); + connection.onRequest(ApplyWorkspaceEditRequest.type, () => ({ + applied: false, + failureReason: 'Read-only LSP session', + })); + connection.onRequest(ShowMessageRequest.type, () => null); + connection.listen(); + + const initializeResult = await bounded('initialize', () => connection!.sendRequest(InitializeRequest.type, { + processId: process.pid, + rootUri, + capabilities: { + general: { positionEncodings: ['utf-16'] }, + textDocument: { documentSymbol: { hierarchicalDocumentSymbolSupport: true } }, + }, + workspaceFolders: [workspaceFolder], + })); + capabilities = initializeResult.capabilities; + await notify('initialized', () => connection!.sendNotification(InitializedNotification.type, {})); + initialized = true; + } catch (error) { + fail(error); + await cleanup(false).catch(() => undefined); + throw terminalError ?? withStderr(error); + } + + const canonicalPathFromUri = (uri: string): string => { + let filepath: string; + try { + const parsed = new URL(uri); + if (parsed.protocol !== 'file:') throw new Error(`Unsupported LSP source URI scheme '${parsed.protocol}'`); + filepath = fileURLToPath(parsed); + } catch (error) { + throw createError(`Invalid LSP file URI '${uri}': ${describe(error)}`, error); + } + return resolve(filepath); + }; + + const isAllowedUri = (uri: string, includeDependencies: boolean): boolean => { + let parsed: URL; + try { + parsed = new URL(uri); + } catch (error) { + throw createError(`Invalid LSP URI '${uri}': ${describe(error)}`, error); + } + if (parsed.protocol !== 'file:') return includeDependencies; + return includeDependencies || isWithinRoot(root, canonicalPathFromUri(uri)); + }; + + const documentForPath = (filepath: string, includeDependencies: boolean): Promise => { + const absolute = resolve(root, filepath); + const relativePath = isWithinRoot(root, absolute) ? relative(root, absolute) : undefined; + if (relativePath === undefined && !includeDependencies) { + return Promise.reject(new Error(`LSP source is outside the project: ${filepath}`)); + } + const uri = pathToFileURL(absolute).href; + const cached = documentPromises.get(uri); + if (cached !== undefined) return cached; + const pending = (async (): Promise => { + let text: string; + if (relativePath !== undefined) text = await project.readText(relativePath); + else { + const data = await readFile(absolute); + try { + text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); + } catch (error) { + throw createError(`LSP source '${absolute}' is not valid UTF-8`, error); + } + } + const document: Document = { + uri, + text, + index: createTextIndex(text), + }; + await notify('textDocument/didOpen', () => connection!.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { uri, languageId: settings.languageId, version: 1, text }, + })); + return document; + })(); + documentPromises.set(uri, pending); + return pending; + }; + + const documentForUri = (uri: string, includeDependencies: boolean): Promise => { + if (!isAllowedUri(uri, includeDependencies)) { + return Promise.reject(new Error(`LSP source is outside the project: ${uri}`)); + } + if (!uri.startsWith('file:')) { + return Promise.reject(new Error(`LSP source URI is not a file URI: ${uri}`)); + } + return documentForPath(canonicalPathFromUri(uri), includeDependencies); + }; + + const filterLocations = (result: unknown, includeDependencies: boolean): readonly Location[] => + normalizeLocations(result).filter(location => isAllowedUri(location.uri, includeDependencies)); + + const symbolQuery = async ( + document: Document, + includeDependencies: boolean, + ): Promise => { + assertCapability('textDocument/documentSymbol', 'documentSymbolProvider'); + let pending = symbolPromises.get(document.uri); + if (pending === undefined) { + pending = request(DocumentSymbolRequest.type, 'textDocument/documentSymbol', { + textDocument: { uri: document.uri }, + }, 'documentSymbolProvider').then(result => result ?? []); + symbolPromises.set(document.uri, pending); + } + const symbols = await pending; + if (symbols.every(isDocumentSymbol)) return symbols; + return (symbols as readonly SymbolInformation[]).filter(symbol => + isAllowedUri(symbol.location.uri, includeDependencies), + ); + }; + + const astFor = (document: Document): Promise => { + let pending = astPromises.get(document.uri); + if (pending === undefined) { + pending = Promise.resolve().then(() => parse(resolveAstGrepLanguage('go'), document.text)); + astPromises.set(document.uri, pending); + } + return pending; + }; + const astNodeRange = (node: SgNode): Range => { + const native = node.range(); + return { + start: { line: native.start.line, character: native.start.column }, + end: { line: native.end.line, character: native.end.column }, + }; + }; + + const rangeOffsets = (document: Document, range: Range): { readonly start: number; readonly end: number } => { + const start = document.index.offset(range.start); + const end = document.index.offset(range.end); + if (end < start) throw new Error('LSP source range ends before it starts'); + return { start, end }; + }; + + const declarationFromRange = ( + document: Document, + range: Range, + ): { readonly source: string; readonly lines: LineSpan } => { + const { start, end } = rangeOffsets(document, range); + const source = document.text.slice(start, end); + if (source.length === 0) throw new Error('LSP declaration range is empty'); + return { source, lines: textLineSpan(range.start.line + 1, source) }; + }; + + const bestSymbolRange = ( + document: Document, + declarations: readonly SymbolDeclaration[], + selectionRange: Range, + ): Range | undefined => { + const completeDeclarations = declarations.filter(symbol => symbol.hasFullRange); + const exact = completeDeclarations.filter(symbol => sameRange(symbol.selectionRange, selectionRange)); + const matching = exact.length > 0 + ? exact + : completeDeclarations.filter(symbol => rangeContains(symbol.selectionRange, selectionRange)); + return matching + .map(symbol => { + const offsets = rangeOffsets(document, symbol.range); + return { range: symbol.range, length: offsets.end - offsets.start }; + }) + .sort((left, right) => left.length - right.length || compareRanges(left.range, right.range))[0]?.range; + }; + + const astDeclarationRange = async (document: Document, selectionRange: Range): Promise<{ + readonly range: Range; + readonly source: string; + readonly startLine: number; + } | undefined> => { + const rootNode = (await astFor(document)).root(); + const selectionStart = selectionRange.start; + const selectionEnd = selectionRange.end; + let best: { readonly node: SgNode; readonly range: Range; readonly length: number } | undefined; + const pendingNodes: SgNode[] = [rootNode]; + while (pendingNodes.length > 0) { + const node = pendingNodes.pop()!; + for (const child of node.children()) pendingNodes.push(child); + if (AST_DECLARATION_KINDS[String(node.kind())] !== true) continue; + const range = astNodeRange(node); + if (!rangeContains(range, { start: selectionStart, end: selectionEnd })) continue; + const offsets = rangeOffsets(document, range); + const length = offsets.end - offsets.start; + if (best === undefined || length < best.length) best = { node, range, length }; + } + if (best === undefined) return undefined; + const range = astNodeRange(best.node); + const native = best.node.range(); + return { range, source: best.node.text(), startLine: native.start.line + 1 }; + }; + + const declarationSource = async ( + item: CallHierarchyItem, + includeDependencies: boolean, + ): Promise => { + const document = await documentForUri(item.uri, includeDependencies); + if (settings.languageId === 'go' && item.kind === SymbolKind.Variable) { + const astDeclaration = await astDeclarationRange(document, item.selectionRange); + if (astDeclaration !== undefined) { + return { + source: astDeclaration.source, + lines: textLineSpan(astDeclaration.startLine, astDeclaration.source), + range: astDeclaration.range, + }; + } + } + const documentSymbolProvider = capabilities?.documentSymbolProvider; + if (documentSymbolProvider !== undefined && documentSymbolProvider !== false) { + const symbols = await symbolQuery(document, includeDependencies); + const declarations = flattenSymbols(symbols); + const symbolRange = bestSymbolRange(document, declarations, item.selectionRange); + if (symbolRange !== undefined) { + return { ...declarationFromRange(document, symbolRange), range: symbolRange }; + } + } + + if (rangeStrictlyContains(item.range, item.selectionRange)) { + return { ...declarationFromRange(document, item.range), range: item.range }; + } + if (settings.languageId === 'go') { + const astDeclaration = await astDeclarationRange(document, item.selectionRange); + if (astDeclaration !== undefined) { + return { + source: astDeclaration.source, + lines: textLineSpan(astDeclaration.startLine, astDeclaration.source), + range: astDeclaration.range, + }; + } + } + throw new Error(`Could not find the full declaration for LSP call hierarchy item '${item.name}' at ${item.uri}`); + }; + + const itemIdentity = (item: CallHierarchyItem): string => { + const filepath = canonicalPathFromUri(item.uri); + const selection = item.selectionRange; + return `${filepath}\0${selection.start.line}:${selection.start.character}-${selection.end.line}:${selection.end.character}`; + }; + + const itemPath = (item: CallHierarchyItem): string => { + const filepath = canonicalPathFromUri(item.uri); + const relativePath = relative(root, filepath); + return isWithinRoot(root, filepath) ? relativePath : filepath; + }; + + const symbolsForTarget = async ( + document: Document, + targetLine: number, + includeDependencies: boolean, + ): Promise => { + const symbols = flattenSymbols(await symbolQuery(document, includeDependencies)); + const line = targetLine - 1; + const sortCandidates = (candidates: readonly SymbolDeclaration[]): readonly SymbolDeclaration[] => + [...candidates].sort((left, right) => { + const leftRange = rangeOffsets(document, left.range); + const rightRange = rangeOffsets(document, right.range); + const leftSize = leftRange.end - leftRange.start; + const rightSize = rightRange.end - rightRange.start; + return leftSize - rightSize || + comparePosition(left.selectionRange.start, right.selectionRange.start) || + left.kind - right.kind || + compareText(left.name, right.name); + }); + const candidates = symbols.filter(symbol => + (settings.languageId !== 'go' || symbol.hasFullRange) && + (symbol.kind === SymbolKind.Function || symbol.kind === SymbolKind.Method) && + symbol.name.trim() !== '' && + symbol.range.start.line <= line && + symbol.range.end.line >= line, + ); + if (candidates.length > 0 || settings.languageId !== 'go') { + return sortCandidates(candidates); + } + + const pendingNodes: SgNode[] = [(await astFor(document)).root()]; + const astCandidates: SymbolDeclaration[] = []; + while (pendingNodes.length > 0) { + const node = pendingNodes.pop()!; + for (const child of node.children()) pendingNodes.push(child); + const nodeKind = String(node.kind()); + if ( + nodeKind !== 'function_declaration' && + nodeKind !== 'method_declaration' && + nodeKind !== 'method_elem' + ) continue; + const range = astNodeRange(node); + if (range.start.line > line || range.end.line < line) continue; + const nameNode = node.field('name'); + if (nameNode === null) continue; + const name = nameNode.text(); + if (name.trim() === '') continue; + astCandidates.push({ + name, + kind: nodeKind === 'function_declaration' ? SymbolKind.Function : SymbolKind.Method, + range, + selectionRange: astNodeRange(nameNode), + hasFullRange: true, + }); + } + return sortCandidates(astCandidates); + }; + + const prepareRoots = async ( + target: LspTarget, + includeDependencies: boolean, + ): Promise => { + const document = await documentForPath(target.filepath, includeDependencies); + let position: Position; + if (target.column !== undefined) position = document.index.position(target.line, target.column); + else { + if (!Number.isSafeInteger(target.line) || target.line < 1 || target.line > document.index.lineCount) { + throw new Error(`LSP line ${target.line} is outside the document`); + } + const candidates = await symbolsForTarget(document, target.line, includeDependencies); + if (candidates.length === 0) return []; + position = candidates[0]!.selectionRange.start; + } + const prepared = await request(CallHierarchyPrepareRequest.type, 'textDocument/prepareCallHierarchy', { + textDocument: { uri: document.uri }, + position, + }, 'callHierarchyProvider'); + return prepared ?? []; + }; + + const traverse = async ( + target: LspTarget, + options: LspCallOptions, + direction: 'callers' | 'callees', + ): Promise => { + if (!Number.isSafeInteger(options.depth) || options.depth < 0) { + throw new Error('LSP call depth must be a nonnegative safe integer'); + } + if (options.depth === 0) return []; + const includeDependencies = options.includeDependencies === true; + const roots = await prepareRoots(target, includeDependencies); + if (roots.length === 0) return []; + const permittedRoots = roots.filter(item => isAllowedUri(item.uri, includeDependencies)); + const rootIdentities = new Set(permittedRoots.map(itemIdentity)); + const accepted = new Map(); + const expanded = new Set(); + const queue: GraphNode[] = []; + + for (const item of permittedRoots) { + const identity = itemIdentity(item); + if (!expanded.has(identity)) { + expanded.add(identity); + queue.push({ item, depth: 0 }); + } + } + + for (let cursor = 0; cursor < queue.length; cursor += 1) { + const current = queue[cursor]!; + if (current.depth >= options.depth) continue; + const response: readonly (CallHierarchyIncomingCall | CallHierarchyOutgoingCall)[] = direction === 'callers' + ? (await request(CallHierarchyIncomingCallsRequest.type, 'callHierarchy/incomingCalls', { item: current.item }, 'callHierarchyProvider')) ?? [] + : (await request(CallHierarchyOutgoingCallsRequest.type, 'callHierarchy/outgoingCalls', { item: current.item }, 'callHierarchyProvider')) ?? []; + for (const edge of response) { + const item = direction === 'callers' + ? (edge as CallHierarchyIncomingCall).from + : (edge as CallHierarchyOutgoingCall).to; + if (!isAllowedUri(item.uri, includeDependencies)) continue; + const identity = itemIdentity(item); + const depth = current.depth + 1; + if (!rootIdentities.has(identity)) { + const previous = accepted.get(identity); + if (previous === undefined || depth < previous.depth) accepted.set(identity, { item, depth }); + } + if (!expanded.has(identity)) { + expanded.add(identity); + queue.push({ item, depth }); + } + } + } + + const results: { readonly call: LspCall; readonly declarationPosition: Position }[] = []; + for (const node of accepted.values()) { + const declaration = await declarationSource(node.item, includeDependencies); + results.push({ + call: { + name: node.item.name, + filepath: itemPath(node.item), + source: declaration.source, + lines: declaration.lines, + depth: node.depth, + }, + declarationPosition: declaration.range.start, + }); + } + results.sort((left, right) => + left.call.depth - right.call.depth || + compareText(left.call.filepath, right.call.filepath) || + comparePosition(left.declarationPosition, right.declarationPosition) || + compareText(left.call.name, right.call.name), + ); + return results.map(result => result.call); + }; + + const navigate = async ( + method: string, + capability: keyof ServerCapabilities, + type: ProtocolRequestType, + target: LspTarget, + options?: LspOptions, + ): Promise => { + const includeDependencies = options?.includeDependencies === true; + const document = await documentForPath(target.filepath, includeDependencies); + const position = document.index.position(target.line, target.column ?? 1); + const result = await request(type, method, { + textDocument: { uri: document.uri }, + position, + }, capability); + return filterLocations(result, includeDependencies); + }; + + const tools: LspTools = { + definitions: (target: LspTarget, options?: LspOptions) => + navigate('textDocument/definition', 'definitionProvider', DefinitionRequest.type, target, options), + declarations: (target: LspTarget, options?: LspOptions) => + navigate('textDocument/declaration', 'declarationProvider', DeclarationRequest.type, target, options), + typeDefinitions: (target: LspTarget, options?: LspOptions) => + navigate('textDocument/typeDefinition', 'typeDefinitionProvider', TypeDefinitionRequest.type, target, options), + implementations: (target: LspTarget, options?: LspOptions) => + navigate('textDocument/implementation', 'implementationProvider', ImplementationRequest.type, target, options), + references: async (target: LspTarget, options?: LspReferenceOptions) => { + const includeDependencies = options?.includeDependencies === true; + const document = await documentForPath(target.filepath, includeDependencies); + const position = document.index.position(target.line, target.column ?? 1); + const result = await request(ReferencesRequest.type, 'textDocument/references', { + textDocument: { uri: document.uri }, + position, + context: { includeDeclaration: options?.includeDeclaration ?? false }, + }, 'referencesProvider'); + return filterLocations(result, includeDependencies); + }, + hover: async (target: LspTarget, options?: LspOptions): Promise => { + const includeDependencies = options?.includeDependencies === true; + const document = await documentForPath(target.filepath, includeDependencies); + const position = document.index.position(target.line, target.column ?? 1); + const result = await request(HoverRequest.type, 'textDocument/hover', { + textDocument: { uri: document.uri }, + position, + }, 'hoverProvider'); + return result ?? null; + }, + documentSymbols: async (filepath: string, options?: LspOptions) => { + const document = await documentForPath(filepath, options?.includeDependencies === true); + return symbolQuery(document, options?.includeDependencies === true); + }, + workspaceSymbols: async (query: string, options?: LspOptions): Promise => { + if (query.length === 0) return []; + const result = await request(WorkspaceSymbolRequest.type, 'workspace/symbol', { query }, 'workspaceSymbolProvider'); + const includeDependencies = options?.includeDependencies === true; + return (result ?? []).filter(symbol => isAllowedUri(symbol.location.uri, includeDependencies)); + }, + callers: (target: LspTarget, options: LspCallOptions) => traverse(target, options, 'callers'), + callees: (target: LspTarget, options: LspCallOptions) => traverse(target, options, 'callees'), + }; + + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closing = true; + controller.abort(new Error('LSP session is closed')); + closePromise = cleanup(initialized && terminalError === undefined); + return closePromise; + }; + + return { ...tools, close }; +} diff --git a/src/app/concurrency.ts b/src/app/concurrency.ts index 1cc82e8..3e6a181 100644 --- a/src/app/concurrency.ts +++ b/src/app/concurrency.ts @@ -12,6 +12,7 @@ export async function mapConcurrent( items: readonly T[], concurrency: number, mapper: (item: T, index: number, reportFailure: (error: unknown) => void) => Promise, + scheduler: (work: Promise) => Promise = work => work, ): Promise { requirePositiveSafeInteger(concurrency); @@ -21,13 +22,14 @@ export async function mapConcurrent( const workerCount = Math.min(items.length, concurrency); let nextIndex = 0; let failureObserved = false; - let failure: unknown; + const { promise: failureSignal, resolve: signalFailure } = + Promise.withResolvers<{ readonly error: unknown }>(); - // A mapper may need to drain started work after it has already failed. + // A mapper may report failure before its own cleanup finishes. const reportFailure = (error: unknown): void => { if (!failureObserved) { failureObserved = true; - failure = error; + signalFailure({ error }); } }; @@ -37,7 +39,8 @@ export async function mapConcurrent( nextIndex += 1; if (index >= items.length) return; try { - results[index] = await mapper(items[index]!, index, reportFailure); + const work = scheduler(Promise.resolve().then(() => mapper(items[index]!, index, reportFailure))); + results[index] = await work; } catch (error) { reportFailure(error); } @@ -45,7 +48,10 @@ export async function mapConcurrent( }; const workers = Array.from({ length: workerCount }, () => runWorker()); - await Promise.all(workers); - if (failureObserved) throw failure; + const outcome = await Promise.race([ + Promise.all(workers).then(() => ({ error: undefined, failed: false as const })), + failureSignal.then(({ error }) => ({ error, failed: true as const })), + ]); + if (outcome.failed) throw outcome.error; return results; } diff --git a/src/app/execute.ts b/src/app/execute.ts index a25b0a2..a241faf 100644 --- a/src/app/execute.ts +++ b/src/app/execute.ts @@ -6,6 +6,7 @@ import type { CheckResult, Grouping, JsonValue, + LspTools, PredicateResult, ProjectContext, Rule, @@ -24,6 +25,7 @@ import type { RunContext, RunEvent, } from './ports.js'; +import { createRunLsp } from './lsp.js'; import { mapConcurrent } from './concurrency.js'; import { evaluateAi } from './evaluators.js'; import { wholeFileScope } from './selection.js'; @@ -35,6 +37,15 @@ import { validateScopeSubjects, } from './validate.js'; +function trackWork(pending: Set>, work: Promise): Promise { + pending.add(work); + void work.then( + () => { pending.delete(work); }, + () => { pending.delete(work); }, + ); + return work; +} + function isEligibleRelativePath(filepath: string): boolean { if (filepath === '' || filepath.startsWith('/')) return false; const segments = filepath.split('/'); @@ -72,6 +83,46 @@ interface Plan { export async function executeRules( rules: readonly LoadedRule[], context: RunContext, +): Promise { + const lsp = createRunLsp(context.project, context.createLspBackend); + const abortLsp = (): void => { void lsp.close().catch(() => undefined); }; + const startedWork = new Set>(); + const scheduleWork = (work: Promise): Promise => { + startedWork.add(work); + void work.then( + () => { startedWork.delete(work); }, + () => { startedWork.delete(work); }, + ); + return work; + }; + const drainStartedWork = async (): Promise => { + while (startedWork.size > 0) await Promise.allSettled([...startedWork]); + }; + let failed = false; + try { + const reports = await executeRun(rules, context, lsp.tools, scheduleWork, abortLsp); + await drainStartedWork(); + return reports; + } catch (error) { + failed = true; + abortLsp(); + await drainStartedWork(); + throw error; + } finally { + try { + await lsp.close(); + } catch (error) { + if (!failed) throw error; + } + } +} + +async function executeRun( + rules: readonly LoadedRule[], + context: RunContext, + lsp: LspTools, + scheduleWork: (work: Promise) => Promise, + abortLsp: () => void, ): Promise { let completedSelections = 0; let completedEvaluations = 0; @@ -98,6 +149,7 @@ export async function executeRules( const projectContextFor = (name: string, evaluationId?: number): ProjectContext => ({ project: context.project, + goLsp: lsp, ...(changedFiles === undefined ? {} : { changedFiles }), ...(changedLines === undefined ? {} : { changedLines }), trace: traceFor(name, evaluationId), @@ -146,6 +198,7 @@ export async function executeRules( selection: Selection, evaluationId?: number, changedOnly = false, + pendingWork?: Set>, ): Promise[]> => { const exclude = selection.exclude ?? []; const candidates = await context.project.glob(selection.files); @@ -168,6 +221,9 @@ export async function executeRules( } const scope = selection.scope; + const scheduler = pendingWork === undefined + ? scheduleWork + : (work: Promise) => trackWork(pendingWork, work); const collected = await mapConcurrent(chosen, context.concurrency, async filepath => { const source = await context.project.readText(filepath); const matches: readonly ScopeMatch[] = @@ -185,8 +241,7 @@ export async function executeRules( subjects.push({ kind: 'scope', filepath, ...valid } as unknown as ScopeSubject); } return subjects; - }); - + }, scheduler); return collected.flat(); }; @@ -214,12 +269,13 @@ export async function executeRules( const { name, rule } = plan; emit({ type: 'evaluation', evaluationId, name, subject }); let trackedUsage: AiUsage | undefined; - let pendingAi: Set> | undefined; + let pendingWork: Set> | undefined; let accountingFailure: { readonly error: Error } | undefined; const retainAccountingFailure = (error: unknown): void => { if (accountingFailure !== undefined) return; const failure = error instanceof Error ? error : new Error(String(error)); accountingFailure = { error: failure }; + abortLsp(); reportFailure(failure); }; const throwIfAccountingFailed = (): void => { @@ -249,19 +305,17 @@ export async function executeRules( const checkContext: CheckContext = { ...projectContextFor(name, evaluationId), ruleName: name, - select: selection => selectFromRule(name, selection, evaluationId), + // Let the check catch a nested selection rejection. If it escapes, the + // enclosing check handler records it before aborting sibling work. + select: selection => selectFromRule( + name, selection, evaluationId, false, pendingWork ??= new Set>(), + ), ai: options => { const call = evaluateAi( subject, options, { ...projectContextFor(name, evaluationId), ruleName: name }, runtimeOf(plan), observer, ); - const pending = pendingAi ??= new Set>(); - pending.add(call); - // Observe both outcomes immediately; the check still receives the original promise. - void call.then( - () => { pending.delete(call); }, - () => { pending.delete(call); }, - ); - return call; + const pending = pendingWork ??= new Set>(); + return trackWork(pending, call); }, }; @@ -294,11 +348,12 @@ export async function executeRules( const cause = accountingFailure === undefined ? error : accountingFailure.error; const failure = cause instanceof Error ? cause : new Error(String(cause)); reportFailure(failure); + abortLsp(); throw failure; } finally { - // Promise.all may reject while sibling calls are still producing usage events. - while (pendingAi !== undefined && pendingAi.size > 0) { - await Promise.allSettled(pendingAi); + // The check can settle while sibling AI calls or selector workers still produce usage. + while (pendingWork !== undefined && pendingWork.size > 0) { + await Promise.allSettled(pendingWork); } } // A custom check may catch context.ai failures, but cannot discard accounting failures. @@ -450,8 +505,11 @@ export async function executeRules( } context.emit?.({ type: 'progress', phase: 'evaluation', completed: 0, total: tasks.length }); - const results = await mapConcurrent(tasks, context.concurrency, (task, _index, reportFailure) => - evaluateSubject(task.plan, task.subject, task.evaluationId, reportFailure), + const results = await mapConcurrent( + tasks, + context.concurrency, + (task, _index, reportFailure) => evaluateSubject(task.plan, task.subject, task.evaluationId, reportFailure), + scheduleWork, ); for (let index = 0; index < tasks.length; index += 1) { const task = tasks[index]!; diff --git a/src/app/lsp.ts b/src/app/lsp.ts new file mode 100644 index 0000000..5e7a749 --- /dev/null +++ b/src/app/lsp.ts @@ -0,0 +1,183 @@ +import type { + DocumentSymbol, + Hover, + Location, + LspCall, + LspCallOptions, + LspOptions, + LspReferenceOptions, + LspTarget, + LspTools, + SymbolInformation, + WorkspaceSymbol, +} from './rule.js'; +import type { LspBackend, LspBackendFactory, Project } from './ports.js'; + +export function createRunLsp( + project: Project, + createBackend?: LspBackendFactory, +): { readonly tools: LspTools; close(): Promise } { + const controller = new AbortController(); + const pending = new Set>(); + let startup: Promise | undefined; + let backend: LspBackend | undefined; + let backendClose: Promise | undefined; + let closeFailure: unknown; + let hasCloseFailure = false; + let closePromise: Promise | undefined; + let closed = false; + + const closedError = (): Error => new Error('LSP session is closed'); + + const closeBackend = (owned: LspBackend): Promise => { + if (backendClose === undefined) { + backendClose = Promise.resolve().then(() => owned.close()); + void backendClose.catch(error => { + closeFailure = error; + hasCloseFailure = true; + }); + } + return backendClose; + }; + + const getBackend = (): Promise => { + if (closed) return Promise.reject(closedError()); + if (startup !== undefined) return startup; + + if (createBackend === undefined) { + startup = Promise.reject(new Error('LSP is not configured for this run')); + void startup.catch(() => undefined); + return startup; + } + + startup = Promise.resolve().then(async () => { + if (controller.signal.aborted) throw closedError(); + const created = await createBackend(project, controller.signal); + backend = created; + if (closed) { + await closeBackend(created); + throw closedError(); + } + return created; + }); + void startup.catch(() => undefined); + return startup; + }; + + const observe = (promise: Promise): Promise => { + pending.add(promise); + void promise.then( + () => pending.delete(promise), + () => pending.delete(promise), + ); + return promise; + }; + const rejectCall = (error: unknown): Promise => observe(Promise.reject(error)); + + const invoke = (operation: (active: LspBackend) => Promise): Promise => + observe(Promise.resolve().then(async () => { + if (closed) throw closedError(); + const active = await getBackend(); + if (closed || controller.signal.aborted) throw closedError(); + return operation(active); + })); + + const validateTarget = (target: LspTarget): void => { + if (!Number.isSafeInteger(target.line) || target.line < 1) { + throw new Error('LSP line must be a positive safe integer'); + } + if (target.column !== undefined && (!Number.isSafeInteger(target.column) || target.column < 1)) { + throw new Error('LSP column must be a positive safe integer'); + } + }; + + const withTarget = ( + target: LspTarget, + operation: (active: LspBackend) => Promise, + ): Promise => { + if (closed) return rejectCall(closedError()); + try { + validateTarget(target); + } catch (error) { + return rejectCall(error); + } + return invoke(operation); + }; + + const withGraph = ( + target: LspTarget, + options: LspCallOptions, + operation: (active: LspBackend) => Promise, + ): Promise => { + if (closed) return rejectCall(closedError()); + if (!Number.isSafeInteger(options.depth) || options.depth < 0) { + return rejectCall(new Error('LSP call depth must be a nonnegative safe integer')); + } + try { + validateTarget(target); + } catch (error) { + return rejectCall(error); + } + if (options.depth === 0) return Promise.resolve([]); + return invoke(operation); + }; + + const tools: LspTools = { + definitions: (target: LspTarget, options?: LspOptions): Promise => + withTarget(target, active => active.definitions(target, options)), + declarations: (target: LspTarget, options?: LspOptions): Promise => + withTarget(target, active => active.declarations(target, options)), + typeDefinitions: (target: LspTarget, options?: LspOptions): Promise => + withTarget(target, active => active.typeDefinitions(target, options)), + implementations: (target: LspTarget, options?: LspOptions): Promise => + withTarget(target, active => active.implementations(target, options)), + references: (target: LspTarget, options?: LspReferenceOptions): Promise => + withTarget(target, active => active.references(target, options)), + hover: (target: LspTarget, options?: LspOptions): Promise => + withTarget(target, active => active.hover(target, options)), + documentSymbols: ( + filepath: string, + options?: LspOptions, + ): Promise => + invoke(active => active.documentSymbols(filepath, options)), + workspaceSymbols: ( + query: string, + options?: LspOptions, + ): Promise => { + if (closed) return rejectCall(closedError()); + return query.length === 0 ? Promise.resolve([]) : invoke(active => active.workspaceSymbols(query, options)); + }, + callers: (target: LspTarget, options: LspCallOptions): Promise => + withGraph(target, options, active => active.callers(target, options)), + callees: (target: LspTarget, options: LspCallOptions): Promise => + withGraph(target, options, active => active.callees(target, options)), + }; + + const close = (): Promise => { + if (closePromise !== undefined) return closePromise; + closed = true; + controller.abort(); + closePromise = Promise.resolve().then(async () => { + if (startup !== undefined) { + try { + backend = await startup; + } catch { + // The request reports startup failures. There is no backend to close. + } + } + if (backend !== undefined) { + try { + await closeBackend(backend); + } catch { + // Preserve the close failure after pending calls settle. + } + } + await Promise.allSettled([...pending]); + if (hasCloseFailure) throw closeFailure; + }); + void closePromise.catch(() => undefined); + return closePromise; + }; + + return { tools, close }; +} diff --git a/src/app/ports.ts b/src/app/ports.ts index 31b3377..cadc4c5 100644 --- a/src/app/ports.ts +++ b/src/app/ports.ts @@ -1,9 +1,11 @@ import type { AiUsage, + Awaitable, CheckResult, GroupSubject, JsonValue, LineRange, + LspTools, Rule, RuleSettings, ScopeSubject, @@ -27,6 +29,11 @@ export interface Project { readYaml(path: string): Promise; readCsv(path: string, options?: CsvOptions): Promise; } +export interface LspBackend extends LspTools { + close(): Promise; +} + +export type LspBackendFactory = (project: Project, signal: AbortSignal) => Awaitable; export interface CsvOptions { readonly headers?: boolean; @@ -89,6 +96,7 @@ export interface DecisionRuntime { export interface RunContext { readonly project: Project; + readonly createLspBackend?: LspBackendFactory; readonly isTarget: (filepath: string) => boolean; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; diff --git a/src/app/rule.ts b/src/app/rule.ts index e9976c7..2a0f6c9 100644 --- a/src/app/rule.ts +++ b/src/app/rule.ts @@ -1,5 +1,20 @@ +import type { + DocumentSymbol, + Hover, + Location, + SymbolInformation, + WorkspaceSymbol, +} from 'vscode-languageserver-protocol'; import type { Project } from './ports.js'; +export type { + DocumentSymbol, + Hover, + Location, + SymbolInformation, + WorkspaceSymbol, +} from 'vscode-languageserver-protocol'; + export type Awaitable = T | Promise; export type JsonValue = @@ -22,6 +37,45 @@ export interface LineRange { readonly end: number; } +export interface LspTarget { + readonly filepath: string; + readonly line: number; + readonly column?: number; +} + +export interface LspOptions { + readonly includeDependencies?: boolean; +} + +export interface LspReferenceOptions extends LspOptions { + readonly includeDeclaration?: boolean; +} + +export interface LspCallOptions extends LspOptions { + readonly depth: number; +} + +export interface LspCall { + readonly name: string; + readonly filepath: string; + readonly source: string; + readonly lines: LineRange; + readonly depth: number; +} + +export interface LspTools { + definitions(target: LspTarget, options?: LspOptions): Promise; + declarations(target: LspTarget, options?: LspOptions): Promise; + typeDefinitions(target: LspTarget, options?: LspOptions): Promise; + implementations(target: LspTarget, options?: LspOptions): Promise; + references(target: LspTarget, options?: LspReferenceOptions): Promise; + hover(target: LspTarget, options?: LspOptions): Promise; + documentSymbols(filepath: string, options?: LspOptions): Promise; + workspaceSymbols(query: string, options?: LspOptions): Promise; + callers(target: LspTarget, options: LspCallOptions): Promise; + callees(target: LspTarget, options: LspCallOptions): Promise; +} + export interface ScopeMatch { readonly id: string; readonly source: string; @@ -120,6 +174,7 @@ export type Evaluator = ( export interface ProjectContext { readonly project: Project; + readonly goLsp: LspTools; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; trace(message: string, details?: JsonValue): void; @@ -154,7 +209,6 @@ export function isInDiff(subject: ScopeSubject, context: ProjectContext return false; } - export const RULE_BRAND: symbol = Symbol.for('coderuler.Rule'); export abstract class Rule { diff --git a/src/index.ts b/src/index.ts index daf0d86..fd1b2ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,21 @@ export type { Awaitable, CheckContext, CheckResult, + DocumentSymbol, Evaluator, Grouping, GroupSubject, + Hover, JsonValue, - PredicateResult, LineRange, + Location, + LspCall, + LspCallOptions, + LspOptions, + LspReferenceOptions, + LspTarget, + LspTools, + PredicateResult, ProjectContext, RuleSettings, ScopeMatch, @@ -23,9 +32,11 @@ export type { SelectionContext, SourceFile, Subject, + SymbolInformation, TokenPricing, TokenUsage, ViolationLocation, + WorkspaceSymbol, } from './app/rule.js'; export { ai, pass, regex, violation } from './app/evaluators.js'; @@ -43,6 +54,8 @@ export type { DecisionRequest, DecisionRuntime, LoadedRule, + LspBackend, + LspBackendFactory, Project, RuleEvaluation, RuleLoader, diff --git a/src/infra/runtime.ts b/src/infra/runtime.ts index b74989c..50debbf 100644 --- a/src/infra/runtime.ts +++ b/src/infra/runtime.ts @@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; +import { createLspBackend } from '../adapter/lsp/backend.js'; import { runProgram } from '../adapter/cli/program.js'; import { terminalCapabilities } from '../adapter/cli/progress.js'; import type { @@ -82,7 +83,11 @@ export function createServices( ...ruleServices, createProject: project.createProject, resolveTarget: project.resolveTarget, - runRules: execution.executeRules, + runRules: (rules, runContext) => + execution.executeRules(rules, { + ...runContext, + createLspBackend: runContext.createLspBackend ?? createLspBackend, + }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), async collectChanges(root: string, base: string) { @@ -114,7 +119,11 @@ export function createServices( ...ruleServices, createProject: project.createProject, resolveTarget: project.resolveTarget, - runRules: execution.executeRules, + runRules: (rules, runContext) => + execution.executeRules(rules, { + ...runContext, + createLspBackend: runContext.createLspBackend ?? createLspBackend, + }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), }; @@ -125,7 +134,7 @@ export function createServices( return { ...ruleServices, testRules: (rules, onResult, timeoutMs, concurrency, includeAI) => - runRuleTests(rules, onResult, timeoutMs, concurrency, includeAI), + runRuleTests(rules, onResult, timeoutMs, concurrency, includeAI, false, createLspBackend), }; }, async update(_context: CliContext) { diff --git a/tests/code-scopes.test.ts b/tests/code-scopes.test.ts index 284bc2e..77ac162 100644 --- a/tests/code-scopes.test.ts +++ b/tests/code-scopes.test.ts @@ -13,6 +13,7 @@ import { SourceLines, textLineSpan } from '../dist/adapter/scope/lines.js'; import { createProject } from '../dist/adapter/filesystem/project.js'; import { protobuf } from '../dist/adapter/scope/protobuf.js'; import type { JsonValue, ProjectContext, ScopeMatch, ScopeSelector } from '../dist/index.js'; +import { createRunLsp } from '../dist/app/lsp.js'; import { ROOT } from './support.ts'; type LineSpan = NonNullable; @@ -31,7 +32,8 @@ function astGrepWith(options: Record): ScopeSelector return astGrep(options as unknown as AstGrepOptions); } -const projectContext: ProjectContext = { project: createProject(ROOT), trace: () => {} }; +const project = createProject(ROOT); +const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; async function select( selector: ScopeSelector, filepath: string, diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index a357d67..475cd65 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -24,6 +24,7 @@ import { type SourceFile, type Subject, } from '../dist/index.js'; +import { createRunLsp } from '../dist/app/lsp.js'; import { evaluateAi } from '../dist/app/evaluators.js'; import type { DecisionBackend, DecisionObserver, DecisionRuntime } from '../dist/app/ports.js'; import { @@ -94,6 +95,7 @@ interface TestContextOptions { class TestContext implements CheckContext { readonly project: Project; + readonly goLsp: ProjectContext['goLsp']; readonly ruleName: string; readonly traces: CapturedTrace[] = []; readonly selections: unknown[] = []; @@ -104,6 +106,7 @@ class TestContext implements CheckContext { constructor(options: TestContextOptions = {}) { this.project = options.project ?? new MemoryProject(); + this.goLsp = createRunLsp(this.project).tools; this.ruleName = options.ruleName ?? 'contract-rule'; this.#subject = options.subject; this.#runtime = options.runtime; @@ -134,6 +137,7 @@ class TestContext implements CheckContext { options, { project: this.project, + goLsp: this.goLsp, ruleName: this.ruleName, trace: (message, details) => this.trace(message, details), }, @@ -171,7 +175,8 @@ function requestAt(backend: RecordingBackend, index = 0): DecisionRequest { } function projectContext(ruleName = 'contract-rule'): ProjectContext & { readonly ruleName: string } { - return { project: new MemoryProject(), ruleName, trace: () => {} }; + const project = new MemoryProject(); + return { project, goLsp: createRunLsp(project).tools, ruleName, trace: () => {} }; } function scopeSubject(subject: Omit, 'kind'>): ScopeSubject { @@ -232,16 +237,14 @@ function expectThrows(label: string, run: () => unknown): void { test('isDiffMode detects available diff data', () => { const cases: readonly (readonly [string, ProjectContext, boolean])[] = [ - ['no diff data', { project: new MemoryProject(), trace: () => {} }, false], + ['no diff data', projectContext(), false], ['changedFiles is present', { - project: new MemoryProject(), + ...projectContext(), changedFiles: new Set(), - trace: () => {}, }, true], ['changedLines is present', { - project: new MemoryProject(), + ...projectContext(), changedLines: new Map(), - trace: () => {}, }, true], ]; @@ -271,9 +274,8 @@ test('isInDiff matches inclusive changed-line ranges', () => { for (const [label, ranges, expected] of cases) { const context: ProjectContext = { - project: new MemoryProject(), + ...projectContext(), changedLines: new Map([['src/a.ts', ranges]]), - trace: () => {}, }; assert.equal(isInDiff(subject, context), expected, label); } @@ -287,22 +289,19 @@ test('isInDiff requires scope lines and known changed-line ranges', () => { lines: { start: 5, end: 7 }, }); const contexts: readonly (readonly [string, ProjectContext])[] = [ - ['absent changedLines map', { project: new MemoryProject(), trace: () => {} }], + ['absent changedLines map', projectContext()], ['empty changedLines map', { - project: new MemoryProject(), + ...projectContext(), changedLines: new Map(), - trace: () => {}, }], ['another filepath only', { - project: new MemoryProject(), + ...projectContext(), changedLines: new Map([['src/b.ts', [{ start: 5, end: 7 }]]]), - trace: () => {}, }], ['changed filepath missing from map', { - project: new MemoryProject(), + ...projectContext(), changedFiles: new Set(['src/a.ts']), changedLines: new Map(), - trace: () => {}, }], ]; @@ -311,15 +310,13 @@ test('isInDiff requires scope lines and known changed-line ranges', () => { } const knownRanges: ProjectContext = { - project: new MemoryProject(), + ...projectContext(), changedLines: new Map([['src/a.ts', [{ start: 5, end: 7 }]]]), - trace: () => {}, }; const subjectWithoutLines = scopeSubject({ filepath: 'src/a.ts', id: 'function', source: 'function' }); assert.equal(isInDiff(subjectWithoutLines, knownRanges), false); }); - test('Rule exposes its documented defaults', async () => { const rule = new DefaultsRule(); assert.equal(rule.enabled, true); @@ -329,7 +326,8 @@ test('Rule exposes its documented defaults', async () => { assert.deepStrictEqual(rule.settings, {}); assert.equal(rule.group, 'scope'); - const context: ProjectContext = { project: new MemoryProject(), trace: () => {} }; + const project = new MemoryProject(); + const context: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; assert.equal(await rule.when(context), true); const subject = scopeSubject({ filepath: 'a.txt', id: '$', source: 'text' }); assert.equal(await rule.filter(subject, context), true); @@ -1053,7 +1051,7 @@ test('an input callback replaces the projected state', async () => { return { custom: context.project.root }; }, }, - { project, ruleName: 'r', trace: () => {} }, + { project, goLsp: createRunLsp(project).tools, ruleName: 'r', trace: () => {} }, runtimeFor(backend), ); diff --git a/tests/engine.test.ts b/tests/engine.test.ts index df89c8c..254c736 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -17,6 +17,7 @@ import type { Grouping, GroupSubject, JsonValue, + LspBackend, LoadedRule, PredicateResult, Project, @@ -239,6 +240,22 @@ async function runRule( return { report, harness }; } +function lifecycleBackend(close: () => Promise): LspBackend { + return { + definitions: async () => [], + declarations: async () => [], + typeDefinitions: async () => [], + implementations: async () => [], + references: async () => [], + hover: async () => null, + documentSymbols: async () => [], + workspaceSymbols: async () => [], + callers: async () => [], + callees: async () => [], + close, + }; +} + function eventsOfType( events: readonly RunEvent[], type: K, @@ -2147,37 +2164,57 @@ test('concurrent duplicate subjects retain task IDs across traces, selections, A assertEvaluationLifecycle(harness.events); }); -test('stage traces and failures remain outside evaluations even after another rule was selected', async () => { +test('run cleanup closes the LSP backend after gate, selection, filter, and grouping failures', async () => { const project = new MemoryProject('/repo', { 'a.txt': 'A' }); const trace = (context: ProjectContext) => { context.trace('setup'); }; for (const stage of ['when', 'select', 'filter', 'group'] as const) { const failure = new Error(`${stage} failed`); const rule = new TestRule({ files: ['a.txt'], - when: context => { + when: async context => { trace(context); - if (stage === 'when') throw failure; + if (stage === 'when') { + await context.goLsp.hover({ filepath: 'a.go', line: 1 }); + throw failure; + } return true; }, select: async context => { trace(context); - if (stage === 'select') throw failure; + if (stage === 'select') { + await context.goLsp.hover({ filepath: 'a.go', line: 1 }); + throw failure; + } return context.select({ files: ['a.txt'] }); }, - filter: (_subject, context) => { + filter: async (_subject, context) => { trace(context); + if (stage === 'filter' || stage === 'group') { + await context.goLsp.hover({ filepath: 'a.go', line: 1 }); + } if (stage === 'filter') throw failure; return true; }, }); if (stage === 'group') rule.group = null as unknown as Grouping; const harness = new Harness(project); + let starts = 0; + let closes = 0; + const context: RunContext = { + ...harness.context, + createLspBackend: async () => { + starts += 1; + return lifecycleBackend(async () => { closes += 1; }); + }, + }; await assert.rejects(() => executeRules([ loaded('selected-first', new TestRule({ files: ['a.txt'] })), loaded('failing-stage', rule), - ], harness.context), stage === 'group' ? /./ : error => error === failure); + ], context), stage === 'group' ? /./ : error => error === failure); + assert.equal(starts, 1); + assert.equal(closes, 1); assert.equal(eventsOfType(harness.events, 'evaluation').length, 0); const errors = eventsOfType(harness.events, 'error'); assert.equal(errors.length, 1); @@ -2191,6 +2228,765 @@ test('stage traces and failures remain outside evaluations even after another ru } }); +test('a selection failure aborts sibling LSP work before selection workers drain', { timeout: 5000 }, async () => { + const failure = new Error('selector failed'); + const requestStarted = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + let closes = 0; + const project = new MemoryProject('/repo', { 'a-fail.txt': 'fail', 'b-wait.txt': 'wait' }); + const rule = new TestRule({ + files: ['*.txt'], + scope: async (file, context) => { + if (file.filepath === 'a-fail.txt') { + await requestStarted.promise; + throw failure; + } + assert.ok(context); + await context.goLsp.hover({ filepath: file.filepath, line: 1 }); + return []; + }, + }); + const harness = new Harness(project, { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + const request = Promise.withResolvers(); + signal.addEventListener('abort', () => request.reject(new Error('LSP request aborted')), { once: true }); + requestStarted.resolve(); + return request.promise.finally(() => requestSettled.resolve()); + }, + }), + }; + + await assert.rejects( + () => executeRules([loaded('selection-lsp-failure', rule)], context), + error => error === failure, + ); + await requestSettled.promise; + assert.equal(closes, 1); +}); + +test('the first concurrent check failure aborts pending LSP work before draining siblings', { timeout: 5000 }, async () => { + const failure = new Error('concurrent check failed'); + const requestStarted = Promise.withResolvers(); + let closes = 0; + const project = new MemoryProject('/repo', { 'a.txt': 'waiting\nfail' }); + const rule = new TestRule({ + files: ['a.txt'], + scope: changedLineScope, + check: async (subject, context) => { + if (subject.kind !== 'scope') return pass(); + if (subject.source === 'fail') { + await context.goLsp.hover({ filepath: 'a.txt', line: 2 }); + throw failure; + } + await context.goLsp.hover({ filepath: 'a.txt', line: 1 }); + return pass(); + }, + }); + const harness = new Harness(project, { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async target => { + if (target.line === 1) { + const pending = Promise.withResolvers(); + signal.addEventListener('abort', () => pending.reject(new Error('LSP request aborted')), { once: true }); + requestStarted.resolve(); + return pending.promise; + } + await requestStarted.promise; + return null; + }, + }), + }; + + await assert.rejects( + () => executeRules([loaded('concurrent-lsp-failure', rule)], context), + error => error === failure, + ); + assert.equal(closes, 1); +}); + +test('a failing check closes LSP before draining a pending AI input', { timeout: 5000 }, async () => { + const failure = new Error('check failed while AI input is pending'); + const requestStarted = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + let inputSettled = false; + let closes = 0; + const rule = new TestRule({ + files: ['a.txt'], + check: async (_subject, context) => { + void context.ai({ + rule: 'pending input', + input: async (_subject, projectContext) => { + try { + await projectContext.goLsp.hover({ filepath: 'a.txt', line: 1 }); + return null; + } finally { + inputSettled = true; + } + }, + }); + await requestStarted.promise; + throw failure; + }, + }); + const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + const request = Promise.withResolvers(); + signal.addEventListener('abort', () => request.reject(new Error('LSP request aborted')), { once: true }); + requestStarted.resolve(); + return request.promise.finally(() => requestSettled.resolve()); + }, + }), + }; + + await assert.rejects( + () => executeRules([loaded('pending-ai-input', rule)], context), + error => error instanceof Error && error === failure && error.message === 'check failed while AI input is pending', + ); + await requestSettled.promise; + assert.equal(inputSettled, true); + assert.equal(closes, 1); +}); + +test('accounting failure during AI drain aborts a sibling LSP input', { timeout: 5000 }, async () => { + const requestStarted = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + const overflowInputStarted = Promise.withResolvers(); + const releaseOverflowInput = Promise.withResolvers(); + let inputSettled = false; + let closes = 0; + const rule = new TestRule({ + files: ['a.txt'], + check: async (_subject, context) => { + await context.ai({ rule: 'Baseline usage' }); + void context.ai({ + rule: 'LSP-backed input', + input: async (_subject, projectContext) => { + try { + await projectContext.goLsp.hover({ filepath: 'a.txt', line: 1 }); + return null; + } finally { + inputSettled = true; + } + }, + }); + void context.ai({ + rule: 'Overflow input', + input: async () => { + overflowInputStarted.resolve(); + await releaseOverflowInput.promise; + return null; + }, + }); + await Promise.all([requestStarted.promise, overflowInputStarted.promise]); + setImmediate(() => releaseOverflowInput.resolve()); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); + harness.backend.usages = [ + { inputTokens: Number.MAX_SAFE_INTEGER, outputTokens: 0 }, + { inputTokens: 1, outputTokens: 0 }, + ]; + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + const request = Promise.withResolvers(); + signal.addEventListener('abort', () => request.reject(new Error('LSP request aborted')), { once: true }); + requestStarted.resolve(); + void request.promise.then(() => requestSettled.resolve(), () => requestSettled.resolve()); + return request.promise; + }, + }), + }; + + let failure: unknown; + await assert.rejects(() => executeRules([loaded('accounting-during-ai-drain', rule)], context), error => { + failure = error; + return error instanceof Error && /inputTokens/.test(error.message); + }); + await requestSettled.promise; + assert.ok(failure instanceof Error); + assert.equal(inputSettled, true); + assert.equal(closes, 1); + assert.deepEqual(eventsOfType(harness.events, 'error').map(event => [event.stage, event.message]), [ + ['check', failure.message], + ]); + assertEvaluationLifecycle(harness.events); +}); + +test('a nested multi-file selector failure aborts sibling LSP work promptly', { timeout: 5000 }, async () => { + const failure = new Error('nested selector failed'); + const hoverStarted = Promise.withResolvers(); + const releaseAfterDeadline = Promise.withResolvers(); + const releaseHover = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + let hoverInvoked = false; + let hoverAborted = false; + let promptDeadlineExpired = false; + let closes = 0; + const rule = new TestRule({ + files: ['entry.txt'], + check: async (_subject, context) => { + await context.select({ + files: ['nested/*.go'], + scope: async (file, projectContext) => { + if (file.filepath === 'nested/a.go') { + await Promise.race([hoverStarted.promise, releaseAfterDeadline.promise]); + throw failure; + } + assert.ok(projectContext); + await projectContext.goLsp.hover({ filepath: file.filepath, line: 1 }); + return []; + }, + }); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'entry.txt': 'entry', + 'nested/a.go': 'fail', + 'nested/b.go': 'wait', + }), { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + hoverInvoked = true; + const request = Promise.withResolvers(); + const onAbort = () => { + hoverAborted = true; + request.reject(new Error('LSP request aborted')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void releaseHover.promise.then(value => request.resolve(value)); + hoverStarted.resolve(); + return request.promise.finally(() => { + signal.removeEventListener('abort', onAbort); + requestSettled.resolve(); + }); + }, + }), + }; + // This watchdog only resolves a stuck backend request after the prompt deadline so a deadlock cannot hang the test. + const cleanupTimer = setTimeout(() => { + promptDeadlineExpired = true; + releaseAfterDeadline.resolve(); + releaseHover.resolve(null); + }, 1000); + + try { + await assert.rejects( + () => executeRules([loaded('nested-selector-deadlock', rule)], context), + error => error === failure, + ); + } finally { + clearTimeout(cleanupTimer); + releaseAfterDeadline.resolve(); + releaseHover.resolve(null); + if (hoverInvoked) await requestSettled.promise; + } + + assert.equal(promptDeadlineExpired, false, 'the run must not wait for test cleanup to drain the sibling hover'); + assert.equal(hoverInvoked, true); + assert.equal(hoverAborted, true, 'the pending hover must reject because the run aborted'); + assert.equal(closes, 1); +}); + +test('a custom selector catches nested selection failure, releases sibling work, and uses LSP', { timeout: 5000 }, async () => { + const failure = new Error('recoverable nested selector failure'); + const bothNestedSelectorsStarted = Promise.withResolvers(); + const selectionErrorCaught = Promise.withResolvers(); + const releaseSibling = Promise.withResolvers(); + const siblingReleased = Promise.withResolvers(); + const finishSibling = Promise.withResolvers(); + const checkFinished = Promise.withResolvers(); + const runFinished = Promise.withResolvers(); + const nestedFiles = new Set(); + let siblingFinished = false; + let deadlineExpired = false; + let queries = 0; + let hoverActive = false; + let activeRequestAborts = 0; + let closes = 0; + const rule = new TestRule({ + files: ['entry.go'], + select: async selectionContext => { + const selectionError = await selectionContext.select({ + files: ['nested/*.go'], + scope: async file => { + nestedFiles.add(file.filepath); + if (nestedFiles.size === 2) bothNestedSelectorsStarted.resolve(); + await bothNestedSelectorsStarted.promise; + if (file.filepath === 'nested/b.go') throw failure; + await releaseSibling.promise; + siblingReleased.resolve(); + await finishSibling.promise; + siblingFinished = true; + return []; + }, + }).then(() => undefined, error => error); + assert.equal(selectionError, failure); + selectionErrorCaught.resolve(); + releaseSibling.resolve(); + return [scopeSubject('entry.go', 'fallback', 'fallback') as unknown as ScopeSubject]; + }, + check: async (subject, context) => { + assert.equal(asScope(subject).id, 'fallback'); + await context.goLsp.hover({ filepath: 'entry.go', line: 1 }); + checkFinished.resolve(); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'entry.go': 'entry', + 'nested/a.go': 'nested a', + 'nested/b.go': 'nested b', + }), { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => { + signal.addEventListener('abort', () => { if (hoverActive) activeRequestAborts += 1; }, { once: true }); + return { + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + hoverActive = true; + try { + queries += 1; + return null; + } finally { + hoverActive = false; + } + }, + }; + }, + }; + // Release both gates only if nested selection waits for the sibling instead of reporting its failure. + const cleanupTimer = setTimeout(() => { + deadlineExpired = true; + releaseSibling.resolve(); + finishSibling.resolve(); + }, 1000); + const runPromise = executeRules([loaded('recovered-custom-selector', rule)], context); + void runPromise.then(() => runFinished.resolve(), () => runFinished.resolve()); + + let reports: readonly RuleReport[] | undefined; + try { + await Promise.all([selectionErrorCaught.promise, siblingReleased.promise, checkFinished.promise]); + const completedWhileSiblingWasHeld = await Promise.race([ + runFinished.promise.then(() => true), + new Promise(resolve => { setImmediate(() => resolve(false)); }), + ]); + assert.equal(deadlineExpired, false, 'the selector catch must run before the cleanup watchdog'); + assert.equal(siblingFinished, false); + assert.equal(completedWhileSiblingWasHeld, false, 'run cleanup must wait for the nested selector sibling'); + finishSibling.resolve(); + reports = await runPromise; + } finally { + clearTimeout(cleanupTimer); + releaseSibling.resolve(); + finishSibling.resolve(); + await runPromise.catch(() => undefined); + } + + assert.ok(reports); + assert.equal(reports[0]?.status, 'pass'); + assert.equal(reports[0]?.evaluations.length, 1); + assert.deepEqual([...nestedFiles].sort(), ['nested/a.go', 'nested/b.go']); + assert.equal(queries, 1); + assert.equal(activeRequestAborts, 0); + assert.equal(closes, 1); + assert.deepEqual(eventsOfType(harness.events, 'error'), []); + assertEvaluationLifecycle(harness.events); +}); + +test('a check catches nested selection failure, drains its sibling, and continues to LSP', { timeout: 5000 }, async () => { + const failure = new Error('recoverable nested selection failure'); + const selectorsStarted = Promise.withResolvers(); + const selectionErrorCaught = Promise.withResolvers(); + const releaseSibling = Promise.withResolvers(); + const siblingReleased = Promise.withResolvers(); + const finishSibling = Promise.withResolvers(); + const checkFinished = Promise.withResolvers(); + const runFinished = Promise.withResolvers(); + const nestedFiles = new Set(); + let siblingFinished = false; + let queries = 0; + let hoverActive = false; + let activeRequestAborts = 0; + const rule = new TestRule({ + files: ['entry.go'], + check: async (_subject, context) => { + const selectionError = await context.select({ + files: ['nested/*.go'], + scope: async file => { + nestedFiles.add(file.filepath); + if (nestedFiles.size === 2) selectorsStarted.resolve(); + await selectorsStarted.promise; + if (file.filepath === 'nested/b.go') throw failure; + await releaseSibling.promise; + siblingReleased.resolve(); + await finishSibling.promise; + siblingFinished = true; + return []; + }, + }).then(() => undefined, error => error); + assert.equal(selectionError, failure); + selectionErrorCaught.resolve(); + releaseSibling.resolve(); + await context.goLsp.hover({ filepath: 'entry.go', line: 1 }); + checkFinished.resolve(); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'entry.go': 'entry', + 'nested/a.go': 'nested a', + 'nested/b.go': 'nested b', + }), { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => { + signal.addEventListener('abort', () => { if (hoverActive) activeRequestAborts += 1; }, { once: true }); + return { + ...lifecycleBackend(async () => {}), + hover: async () => { + hoverActive = true; + try { + queries += 1; + return null; + } finally { + hoverActive = false; + } + }, + }; + }, + }; + const runPromise = executeRules([loaded('caught-check-selector', rule)], context); + void runPromise.then(() => runFinished.resolve(), () => runFinished.resolve()); + + let reports: readonly RuleReport[] | undefined; + try { + await Promise.all([selectionErrorCaught.promise, siblingReleased.promise, checkFinished.promise]); + const completedWhileSiblingWasHeld = await Promise.race([ + runFinished.promise.then(() => true), + new Promise(resolve => { setImmediate(() => resolve(false)); }), + ]); + assert.equal(siblingFinished, false); + assert.equal(completedWhileSiblingWasHeld, false, 'run must await the nested selector sibling'); + finishSibling.resolve(); + reports = await runPromise; + } finally { + releaseSibling.resolve(); + finishSibling.resolve(); + await runPromise.catch(() => undefined); + } + + assert.ok(reports); + assert.equal(reports[0]?.status, 'pass'); + assert.equal(queries, 1); + assert.equal(activeRequestAborts, 0); + assert.deepEqual([...nestedFiles].sort(), ['nested/a.go', 'nested/b.go']); +}); + +test('a check accounts for AI usage from a selector worker drained after it returns', { timeout: 5000 }, async () => { + const selectorStarted = Promise.withResolvers(); + const selectionErrorCaught = Promise.withResolvers(); + const siblingReleased = Promise.withResolvers(); + const finishSibling = Promise.withResolvers(); + const siblingGate = Promise.withResolvers(); + let nestedCount = 0; + let checkContext: CheckContext | undefined; + const rule = new TestRule({ + files: ['entry.txt'], + check: async (_subject, context) => { + checkContext = context; + const selectionError = await context.select({ + files: ['nested/*.txt'], + scope: async file => { + nestedCount += 1; + if (nestedCount === 2) selectorStarted.resolve(); + await selectorStarted.promise; + if (file.filepath === 'nested/b.txt') throw new Error('nested selection failed'); + await selectionErrorCaught.promise; + siblingReleased.resolve(); + await finishSibling.promise; + await siblingGate.promise; + await checkContext?.ai({ rule: 'Late selector policy' }); + return []; + }, + }).then(() => undefined, error => error); + assert.ok(selectionError instanceof Error); + selectionErrorCaught.resolve(); + setImmediate(() => siblingGate.resolve()); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'entry.txt': 'entry', + 'nested/a.txt': 'a', + 'nested/b.txt': 'b', + }), { concurrency: 2 }); + harness.backend.usages = [{ inputTokens: 23, outputTokens: 7 }]; + + const completed = executeRules([loaded('late-selector-usage', rule)], harness.context); + await siblingReleased.promise; + finishSibling.resolve(); + const [report] = await completed; + + const expectedUsage = { + calls: 1, unreportedCalls: 0, inputTokens: 23, outputTokens: 7, + inputCost: 23 * 0.042 / 1_000_000, outputCost: 0, + }; + assert.deepEqual(report?.evaluations[0]?.result.usage, expectedUsage); + assert.deepEqual(eventsOfType(harness.events, 'usage').map(event => event.usage), [expectedUsage]); + const resultIndex = harness.events.findIndex(event => event.type === 'result'); + assert.ok(resultIndex >= 0); + assert.equal(harness.events.slice(resultIndex + 1).some(event => event.type === 'usage'), false); +}); + +test('a fatal nested selection waits for deferred non-LSP selector work to settle', { timeout: 5000 }, async () => { + const failure = new Error('fatal nested selection failure'); + const selectorsStarted = Promise.withResolvers(); + const releaseSibling = Promise.withResolvers(); + const siblingStarted = Promise.withResolvers(); + const siblingSettled = Promise.withResolvers(); + const runFinished = Promise.withResolvers(); + let settled = false; + const rule = new TestRule({ + files: ['entry.go'], + check: async (_subject, context) => { + await context.select({ + files: ['nested/*.go'], + scope: async file => { + if (file.filepath === 'nested/b.go') { + siblingStarted.resolve(); + await releaseSibling.promise; + settled = true; + siblingSettled.resolve(); + return []; + } + selectorsStarted.resolve(); + await siblingStarted.promise; + throw failure; + }, + }); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'entry.go': 'entry', + 'nested/a.go': 'nested a', + 'nested/b.go': 'nested b', + }), { concurrency: 2 }); + const runPromise = executeRules([loaded('fatal-check-selector', rule)], harness.context); + void runPromise.then(() => runFinished.resolve(), () => runFinished.resolve()); + + try { + await Promise.all([selectorsStarted.promise, siblingStarted.promise]); + const completedBeforeSiblingSettled = await Promise.race([ + runFinished.promise.then(() => true), + new Promise(resolve => { setImmediate(() => resolve(false)); }), + ]); + assert.equal(settled, false); + assert.equal(completedBeforeSiblingSettled, false, 'fatal run must drain selector work it owns'); + releaseSibling.resolve(); + await assert.rejects(() => runPromise, error => error === failure); + await siblingSettled.promise; + assert.equal(settled, true); + } finally { + releaseSibling.resolve(); + await runPromise.catch(() => undefined); + } +}); + +test('caught nested selection errors do not poison evaluations or close LSP', async () => { + const failure = new Error('recoverable nested selector failure'); + let queries = 0; + let closes = 0; + const rule = new TestRule({ + files: ['eval.txt'], + scope: changedLineScope, + check: async (subject, context) => { + if (subject.kind !== 'scope') return pass(); + if (subject.source === 'recover') { + const selectionError = await context.select({ + files: ['nested.txt'], + scope: async () => { throw failure; }, + }).then(() => undefined, error => error); + assert.equal(selectionError, failure); + } else { + await context.goLsp.hover({ filepath: 'eval.txt', line: 2 }); + } + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'eval.txt': 'recover\nlsp', + 'nested.txt': 'nested', + }), { concurrency: 1 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async () => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { queries += 1; return null; }, + }), + }; + + const reports = await executeRules([loaded('caught-nested-selection', rule)], context); + assert.equal(reports[0]?.status, 'pass'); + assert.equal(reports[0]?.evaluations.length, 2); + assert.equal(queries, 1); + assert.equal(closes, 1); + assert.deepEqual(eventsOfType(harness.events, 'error'), []); + assertEvaluationLifecycle(harness.events); +}); + +test('nested selection failure is recorded before sibling LSP work is aborted', { timeout: 5000 }, async () => { + const failure = new Error('nested selector failed'); + const requestStarted = Promise.withResolvers(); + const requestSettled = Promise.withResolvers(); + let closes = 0; + const rule = new TestRule({ + files: ['eval.txt'], + scope: changedLineScope, + check: async (subject, context) => { + if (subject.kind !== 'scope') return pass(); + if (subject.source === 'fail') { + await context.select({ + files: ['nested.txt'], + scope: async () => { + await requestStarted.promise; + throw failure; + }, + }); + return pass(); + } + await context.goLsp.hover({ filepath: 'eval.txt', line: 2 }); + return pass(); + }, + }); + const harness = new Harness(new MemoryProject('/repo', { + 'eval.txt': 'fail\nwait', + 'nested.txt': 'nested', + }), { concurrency: 2 }); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { closes += 1; }), + hover: async () => { + const request = Promise.withResolvers(); + signal.addEventListener('abort', () => request.reject(new Error('LSP request aborted')), { once: true }); + requestStarted.resolve(); + void request.promise.then(() => requestSettled.resolve(), () => requestSettled.resolve()); + return request.promise; + }, + }), + }; + + await assert.rejects( + () => executeRules([loaded('nested-selection-failure', rule)], context), + error => error instanceof Error && error === failure && error.message === 'nested selector failed', + ); + await requestSettled.promise; + assert.equal(closes, 1); + assert.deepEqual(eventsOfType(harness.events, 'error').map(event => event.message)[0], failure.message); +}); + +test('run cleanup preserves rule errors and reports standalone backend close failures', async () => { + const project = new MemoryProject('/repo', { 'a.txt': 'A' }); + const ruleFailure = new Error('check failed'); + const closeFailure = new Error('backend close failed'); + const rule = new TestRule({ + files: ['a.txt'], + when: async context => { + await context.goLsp.hover({ filepath: 'a.go', line: 1 }); + return true; + }, + check: async () => { throw ruleFailure; }, + }); + const harness = new Harness(project); + const context: RunContext = { + ...harness.context, + createLspBackend: async () => lifecycleBackend(async () => { throw closeFailure; }), + }; + + await assert.rejects(() => executeRules([loaded('failing-check', rule)], context), error => error === ruleFailure); + + const passing = new TestRule({ + files: ['a.txt'], + when: async context => { + await context.goLsp.hover({ filepath: 'a.go', line: 1 }); + return true; + }, + }); + await assert.rejects(() => executeRules([loaded('passing', passing)], context), error => error === closeFailure); +}); + +test('run cleanup aborts pending backend startup after a gate failure', async () => { + const project = new MemoryProject('/repo', { 'a.txt': 'A' }); + const failure = new Error('gate failed'); + const factoryStarted = Promise.withResolvers(); + const rule = new TestRule({ + when: async context => { + void context.goLsp.hover({ filepath: 'a.go', line: 1 }); + await factoryStarted.promise; + throw failure; + }, + }); + const harness = new Harness(project); + const context: RunContext = { + ...harness.context, + createLspBackend: async (_project, signal) => { + const startup = Promise.withResolvers(); + signal.addEventListener('abort', () => startup.reject(new Error('startup aborted')), { once: true }); + factoryStarted.resolve(); + return startup.promise; + }, + }; + + await assert.rejects(() => executeRules([loaded('pending-startup', rule)], context), error => error === failure); +}); + +test('each rule run gets a fresh lazy backend and non-LSP runs do not start one', async () => { + const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); + let starts = 0; + let closes = 0; + const context: RunContext = { + ...harness.context, + createLspBackend: async () => { + starts += 1; + return lifecycleBackend(async () => { closes += 1; }); + }, + }; + + await executeRules([loaded('plain', new TestRule({ files: ['a.txt'] }))], context); + assert.equal(starts, 0); + const lspRule = new TestRule({ + when: async projectContext => { + await projectContext.goLsp.hover({ filepath: 'a.go', line: 1 }); + return true; + }, + }); + await executeRules([loaded('uses-lsp', lspRule)], context); + await executeRules([loaded('uses-lsp', lspRule)], context); + assert.equal(starts, 2); + assert.equal(closes, 2); +}); + test('evaluation task IDs restart at one for each run and skip unevaluated rules', async () => { const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); const rules = [ diff --git a/tests/fixtures/lsp/expected.json b/tests/fixtures/lsp/expected.json new file mode 100644 index 0000000..7a3a0dd --- /dev/null +++ b/tests/fixtures/lsp/expected.json @@ -0,0 +1,158 @@ +{ + "seedCalleesDepth2": [ + { + "filepath": "other/other.go", + "source": "func Shared() {}", + "lines": { "start": 3, "end": 3 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func First() {\n\tLeaf()\n}", + "lines": { "start": 16, "end": 18 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Second() {\n\tLeaf()\n}", + "lines": { "start": 20, "end": 22 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Shared() {}", + "lines": { "start": 30, "end": 30 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Leaf() {\n\tDeep()\n}", + "lines": { "start": 24, "end": 26 }, + "depth": 2 + } + ], + "seedCalleesWithDependenciesDepth2": [ + { + "filepath": "/dep.go", + "source": "func External() {\n\tInner()\n}", + "lines": { "start": 3, "end": 5 }, + "depth": 1 + }, + { + "filepath": "other/other.go", + "source": "func Shared() {}", + "lines": { "start": 3, "end": 3 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func First() {\n\tLeaf()\n}", + "lines": { "start": 16, "end": 18 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Second() {\n\tLeaf()\n}", + "lines": { "start": 20, "end": 22 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Shared() {}", + "lines": { "start": 30, "end": 30 }, + "depth": 1 + }, + { + "filepath": "/dep.go", + "source": "func Inner() {\n\tDeep()\n}", + "lines": { "start": 7, "end": 9 }, + "depth": 2 + }, + { + "filepath": "seed.go", + "source": "func Leaf() {\n\tDeep()\n}", + "lines": { "start": 24, "end": 26 }, + "depth": 2 + } + ], + "seedCallersDepth2": [ + { + "filepath": "seed.go", + "source": "func Caller() {\n\tSeed()\n}", + "lines": { "start": 32, "end": 34 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Grandcaller() {\n\tCaller()\n}", + "lines": { "start": 36, "end": 38 }, + "depth": 2 + } + ], + "firstCallCalleesDepth1": [ + { + "filepath": "seed.go", + "source": "func Leaf() {\n\tDeep()\n}", + "lines": { "start": 24, "end": 26 }, + "depth": 1 + } + ], + "cycleACalleesDepth3": [ + { + "filepath": "seed.go", + "source": "func CycleB() {\n\tCycleA()\n}", + "lines": { "start": 48, "end": 50 }, + "depth": 1 + } + ], + "workerRunCalleesDepth2": [ + { + "filepath": "seed.go", + "source": "func Second() {\n\tLeaf()\n}", + "lines": { "start": 20, "end": 22 }, + "depth": 1 + }, + { + "filepath": "seed.go", + "source": "func Leaf() {\n\tDeep()\n}", + "lines": { "start": 24, "end": 26 }, + "depth": 2 + } + ], + "initializerCallersDepth2": [ + { + "filepath": "initializers.go", + "source": "var _ = InitTarget()", + "lines": { "start": 4, "end": 4 }, + "depth": 1 + }, + { + "filepath": "initializers.go", + "source": "var NamedInitializer = InitTarget()", + "lines": { "start": 5, "end": 5 }, + "depth": 1 + } + ], + "unicodeFirstCallCallerDepth1": [ + { + "filepath": "seed.go", + "source": "func Seed() {\n\tFirst()\n\tSecond()\n\tShared()\n\tother.Shared()\n\tlspdep.External()\n}", + "lines": { "start": 8, "end": 14 }, + "depth": 1 + }, + { + "filepath": "unicode.go", + "source": "func Unicode() { _ = \"😀\"; First() }", + "lines": { "start": 3, "end": 3 }, + "depth": 1 + } + ], + "unicodeFirstCalleesDepth1": [ + { + "filepath": "seed.go", + "source": "func First() {\n\tLeaf()\n}", + "lines": { "start": 16, "end": 18 }, + "depth": 1 + } + ] +} diff --git a/tests/fixtures/lsp/project-dependency/dep.go b/tests/fixtures/lsp/project-dependency/dep.go new file mode 100644 index 0000000..44890c2 --- /dev/null +++ b/tests/fixtures/lsp/project-dependency/dep.go @@ -0,0 +1,11 @@ +package lspdep + +func External() { + Inner() +} + +func Inner() { + Deep() +} + +func Deep() {} diff --git a/tests/fixtures/lsp/project-dependency/go.mod b/tests/fixtures/lsp/project-dependency/go.mod new file mode 100644 index 0000000..f0ad211 --- /dev/null +++ b/tests/fixtures/lsp/project-dependency/go.mod @@ -0,0 +1,3 @@ +module example.com/lspdep + +go 1.27.0 diff --git a/tests/fixtures/lsp/project/.coderuler/go-critical-save-callers.rule.ts b/tests/fixtures/lsp/project/.coderuler/go-critical-save-callers.rule.ts new file mode 100644 index 0000000..a5700d2 --- /dev/null +++ b/tests/fixtures/lsp/project/.coderuler/go-critical-save-callers.rule.ts @@ -0,0 +1,29 @@ +import { basename } from 'node:path'; +import { Rule, pass, scopes, violation, type ProjectContext, type Subject } from '@alex-dbs/coderuler'; + +const ALLOWED_CALLER = 'CriticalServiceSave'; +const ALLOWED_FILE = 'service.go'; + +export default class GoCriticalSaveCallers extends Rule { + files = ['**/*.go']; + scope = scopes.astGrep({ + language: 'go', + rule: { pattern: 'func criticalSave() { $$$BODY }' }, + }); + + check = async (subject: Subject, context: ProjectContext) => { + if (subject.kind !== 'scope') return pass(); + + const callers = await context.goLsp.callers( + { filepath: subject.filepath, line: subject.lines!.start }, + { depth: 1 }, + ); + const identities = callers.map(caller => `${caller.filepath}:${caller.name}`); + const allowed = callers.every(caller => caller.name === ALLOWED_CALLER && basename(caller.filepath) === ALLOWED_FILE); + context.trace('criticalSave callers', identities); + + return allowed + ? pass() + : violation(`Call criticalSave only from ${ALLOWED_CALLER} in ${ALLOWED_FILE}; found ${identities.join(', ')}`); + }; +} diff --git a/tests/fixtures/lsp/project/.coderuler/go-external-definition.rule.ts b/tests/fixtures/lsp/project/.coderuler/go-external-definition.rule.ts new file mode 100644 index 0000000..796340a --- /dev/null +++ b/tests/fixtures/lsp/project/.coderuler/go-external-definition.rule.ts @@ -0,0 +1,28 @@ +import { Rule, pass, scopes, violation, type ProjectContext, type Subject } from '@alex-dbs/coderuler'; + +const EXTERNAL_CALL = 'lspdep.External()'; +const EXTERNAL_SYMBOL = 'External'; +const DEPENDENCY_FILE = '/project-dependency/dep.go'; + +export default class GoExternalDefinition extends Rule { + files = ['**/*.go']; + scope = scopes.astGrep({ language: 'go', rule: { kind: 'function_declaration' } }); + + check = async (subject: Subject, context: ProjectContext) => { + if (subject.kind !== 'scope' || !subject.source.includes(EXTERNAL_CALL)) return pass(); + + const lines = subject.source.split('\n'); + const lineOffset = lines.findIndex(line => line.includes(EXTERNAL_CALL)); + const column = lines[lineOffset].indexOf(EXTERNAL_SYMBOL) + 1; + const definitions = await context.goLsp.definitions({ + filepath: subject.filepath, + line: subject.lines!.start + lineOffset, + column, + }, { includeDependencies: true }); + + context.trace('Go dependency definition', definitions.map(definition => definition.uri)); + return definitions.some(definition => definition.uri.endsWith(DEPENDENCY_FILE)) + ? pass() + : violation('Resolve lspdep.External to the local dependency'); + }; +} diff --git a/tests/fixtures/lsp/project/.coderuler/lsp-context.rule.ts b/tests/fixtures/lsp/project/.coderuler/lsp-context.rule.ts new file mode 100644 index 0000000..6617c5a --- /dev/null +++ b/tests/fixtures/lsp/project/.coderuler/lsp-context.rule.ts @@ -0,0 +1,34 @@ +import { Rule, pass, scopes, violation, type ProjectContext, type Subject } from '@alex-dbs/coderuler'; + +const EXPECTED_CALLEES = ['First:1', 'Leaf:2', 'Second:1', 'Shared:1', 'Shared:1']; +const EXPECTED_CALLERS = ['Caller:1', 'Grandcaller:2']; + +export default class LspContext extends Rule { + files = ['**/*.go']; + scope = scopes.astGrep({ + language: 'go', + rule: { any: [{ kind: 'function_declaration' }, { kind: 'method_declaration' }] }, + }); + + check = async (subject: Subject, context: ProjectContext) => { + if (subject.kind !== 'scope' || !subject.source.includes('func Seed()')) return pass(); + const target = { filepath: subject.filepath, line: subject.lines!.start }; + const [callers, callees, symbols] = await Promise.all([ + context.goLsp.callers(target, { depth: 2 }), + context.goLsp.callees(target, { depth: 2 }), + context.goLsp.documentSymbols(subject.filepath), + ]); + const callerIdentities = callers.map(call => `${call.name}:${call.depth}`).sort(); + const calleeIdentities = callees.map(call => `${call.name}:${call.depth}`).sort(); + const symbolNames = symbols.map(symbol => symbol.name); + context.trace('Go LSP call graph', { + callers: callerIdentities, + callees: calleeIdentities, + symbols: symbolNames, + }); + const valid = JSON.stringify(callerIdentities) === JSON.stringify(EXPECTED_CALLERS) + && JSON.stringify(calleeIdentities) === JSON.stringify(EXPECTED_CALLEES) + && symbolNames.includes('Seed'); + return valid ? pass() : violation('Go LSP call graph did not match the fixture'); + }; +} diff --git a/tests/fixtures/lsp/project/critical_save.go b/tests/fixtures/lsp/project/critical_save.go new file mode 100644 index 0000000..ea8fa7b --- /dev/null +++ b/tests/fixtures/lsp/project/critical_save.go @@ -0,0 +1,7 @@ +package lspfixture + +func criticalSave() { + storeCriticalState() +} + +func storeCriticalState() {} diff --git a/tests/fixtures/lsp/project/go.mod b/tests/fixtures/lsp/project/go.mod new file mode 100644 index 0000000..6739bb4 --- /dev/null +++ b/tests/fixtures/lsp/project/go.mod @@ -0,0 +1,7 @@ +module example.com/lspfixture + +go 1.27.0 + +require example.com/lspdep v0.0.0 + +replace example.com/lspdep => ../project-dependency diff --git a/tests/fixtures/lsp/project/initializers.go b/tests/fixtures/lsp/project/initializers.go new file mode 100644 index 0000000..94e57a0 --- /dev/null +++ b/tests/fixtures/lsp/project/initializers.go @@ -0,0 +1,7 @@ +package lspfixture + +const Greeting = "😀" +var _ = InitTarget() +var NamedInitializer = InitTarget() + +func InitTarget() int { return 1 } diff --git a/tests/fixtures/lsp/project/other/other.go b/tests/fixtures/lsp/project/other/other.go new file mode 100644 index 0000000..7edd328 --- /dev/null +++ b/tests/fixtures/lsp/project/other/other.go @@ -0,0 +1,3 @@ +package other + +func Shared() {} diff --git a/tests/fixtures/lsp/project/seed.go b/tests/fixtures/lsp/project/seed.go new file mode 100644 index 0000000..575408d --- /dev/null +++ b/tests/fixtures/lsp/project/seed.go @@ -0,0 +1,50 @@ +package lspfixture + +import ( + "example.com/lspdep" + "example.com/lspfixture/other" +) + +func Seed() { + First() + Second() + Shared() + other.Shared() + lspdep.External() +} + +func First() { + Leaf() +} + +func Second() { + Leaf() +} + +func Leaf() { + Deep() +} + +func Deep() {} + +func Shared() {} + +func Caller() { + Seed() +} + +func Grandcaller() { + Caller() +} + +func Greatcaller() { + Grandcaller() +} + +func CycleA() { + CycleB() +} + +func CycleB() { + CycleA() +} diff --git a/tests/fixtures/lsp/project/service.go b/tests/fixtures/lsp/project/service.go new file mode 100644 index 0000000..b54c01a --- /dev/null +++ b/tests/fixtures/lsp/project/service.go @@ -0,0 +1,5 @@ +package lspfixture + +func CriticalServiceSave() { + criticalSave() +} diff --git a/tests/fixtures/lsp/project/unicode.go b/tests/fixtures/lsp/project/unicode.go new file mode 100644 index 0000000..ab8a15d --- /dev/null +++ b/tests/fixtures/lsp/project/unicode.go @@ -0,0 +1,3 @@ +package lspfixture + +func Unicode() { _ = "😀"; First() } diff --git a/tests/fixtures/lsp/project/worker.go b/tests/fixtures/lsp/project/worker.go new file mode 100644 index 0000000..6df5b4f --- /dev/null +++ b/tests/fixtures/lsp/project/worker.go @@ -0,0 +1,16 @@ +package lspfixture + +type Runner interface { + Run() +} + +type Worker struct{} + +func (Worker) Run() { + Second() +} + +func Use(r Runner, w Worker) { + r.Run() + w.Run() +} diff --git a/tests/lsp-cli-smoke.test.ts b/tests/lsp-cli-smoke.test.ts new file mode 100644 index 0000000..6014cea --- /dev/null +++ b/tests/lsp-cli-smoke.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { cpSync, readdirSync } from 'node:fs'; +import { after, test } from 'node:test'; +import { join } from 'node:path'; + +import { createSandbox, ROOT, runCli, writeFile } from './support.ts'; + +const sandbox = createSandbox('coderuler-lsp-cli-'); + +after(() => sandbox.cleanup()); + +function fixture(label: string) { + const target = sandbox.isolated(label); + const source = join(ROOT, 'tests', 'fixtures', 'lsp'); + const project = join(source, 'project'); + for (const entry of readdirSync(project)) { + cpSync(join(project, entry), join(target.cwd, entry), { recursive: true }); + } + cpSync(join(source, 'project-dependency'), join(target.root, 'project-dependency'), { recursive: true }); + return target; +} + +const offlineGo = { + GOPROXY: 'off', + GOWORK: 'off', + GOTOOLCHAIN: 'local', + GOTELEMETRY: 'off', +}; + +function assertTrace(output: string): void { + assert.match(output, /Go LSP call graph/); + assert.match(output, /Grandcaller:2/); + assert.match(output, /Caller:1/); + assert.match(output, /First:1/); + assert.match(output, /Second:1/); + assert.match(output, /Leaf:2/); + assert.match(output, /Seed/); +} + +test('check uses the default gopls backend and evaluates the Go fixture call graph', async () => { + const target = fixture('check'); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/lsp-context.rule.ts', '--json', '--output-all', '--debug', + ], target, offlineGo); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, /Go LSP call graph/); + assert.match(result.stdout, /"status":\s*"pass"/); +}); + +test('inspect uses the default gopls backend and reports the same evaluated call graph', async () => { + const target = fixture('inspect'); + const result = await runCli(['inspect', '.coderuler/lsp-context.rule.ts', '.', '--json'], target, offlineGo); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stderr, ''); + assertTrace(result.stdout); + assert.match(result.stdout, /"status":\s*"pass"/); +}); + +test('check resolves Go definitions from a local dependency through context.goLsp', async () => { + const target = fixture('definitions'); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/go-external-definition.rule.ts', '--json', '--output-all', '--debug', + ], target, offlineGo); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(`${result.stdout}\n${result.stderr}`, /Go dependency definition/); + assert.match(result.stdout, /"status":\s*"pass"/); +}); + +test('criticalSave only allows CriticalServiceSave from service.go', async () => { + const args = ['check', '.', '--rules', '.coderuler/go-critical-save-callers.rule.ts', '--json', '--output-all']; + const allowed = await runCli(args, fixture('critical-save-allowed'), offlineGo); + assert.equal(allowed.status, 0, `${allowed.stdout}\n${allowed.stderr}`); + assert.match(allowed.stdout, /"status":\s*"pass"/); + + const target = fixture('critical-save-disallowed'); + writeFile(join(target.cwd, 'unexpected.go'), `package lspfixture + +func Other() { + criticalSave() +} +`); + const disallowed = await runCli(args, target, offlineGo); + assert.equal(disallowed.status, 1, disallowed.stderr || disallowed.stdout); + assert.match(disallowed.stdout, /"status":\s*"violation"/); + assert.match(disallowed.stdout, /unexpected\.go:Other/); +}); diff --git a/tests/lsp-process.test.ts b/tests/lsp-process.test.ts new file mode 100644 index 0000000..853f877 --- /dev/null +++ b/tests/lsp-process.test.ts @@ -0,0 +1,405 @@ +import assert from 'node:assert/strict'; +import { watch } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { test } from 'node:test'; + +import { createProject } from '../dist/adapter/filesystem/project.js'; +import { createLspBackend } from '../dist/adapter/lsp/backend.js'; +import type { LspBackend, Project } from '../dist/index.js'; + +const protocolNodeModuleUrl = import.meta.resolve('vscode-languageserver-protocol/node'); +const source = 'package processfixture\n\nfunc Target() {}\n'; + +interface TestProject { + readonly root: string; + readonly sourcePath: string; + readonly pidPath: string; + readonly project: Project; +} + +async function makeProject(): Promise { + const root = await mkdtemp(join(tmpdir(), 'rulekeeper lsp process-')); + const sourcePath = join(root, 'main.go'); + await writeFile(sourcePath, source); + return { + root, + sourcePath, + pidPath: join(root, 'child.pid'), + project: createProject(root), + }; +} + +async function removeProject(project: TestProject): Promise { + await rm(project.root, { recursive: true, force: true }); +} + +function nodeArgs(script: string): string[] { + return ['--input-type=module', '-e', script]; +} + +function serverOptions(command: string, args: readonly string[], requestTimeoutMs: number) { + return { command, args, languageId: 'go', requestTimeoutMs } as const; +} + +async function waitForMarker(filepath: string, isComplete: (value: string) => boolean): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + const watcher = watch(dirname(filepath)); + // Use filesystem events for synchronization; this timer only bounds a missing marker. + const timer = setTimeout(() => finish(new Error(`Timed out waiting for ${basename(filepath)}`)), 5_000); + + const finish = (error?: Error, value?: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + watcher.close(); + if (error !== undefined) reject(error); + else resolve(value!); + }; + + const check = (): void => { + void readFile(filepath, 'utf8').then( + value => { + try { + if (isComplete(value)) finish(undefined, value); + } catch { + // A marker can be visible while its writer is still publishing it. + } + }, + error => { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return; + finish(error instanceof Error ? error : new Error(String(error))); + }, + ); + }; + + watcher.on('change', check); + watcher.on('rename', check); + watcher.on('error', error => finish(error)); + check(); + return promise; +} + +function validPidMarker(value: string): boolean { + const pid = Number(value); + return Number.isSafeInteger(pid) && pid > 0; +} + +function validJsonMarker(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +function pidFromMarker(marker: string): number { + const pid = Number(marker); + assert.ok(Number.isSafeInteger(pid) && pid > 0, `invalid child PID marker: ${marker}`); + return pid; +} + +function assertProcessExited(pid: number): void { + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); +} + +async function waitForProcessExit(pid: number): Promise { + while (true) { + try { + process.kill(pid, 0); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + await promise; + } +} + +async function terminateChild(pidPath: string): Promise { + let pid: number; + try { + pid = pidFromMarker(await readFile(pidPath, 'utf8')); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return; + throw error; + } + + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') return; + throw error; + } + await waitForProcessExit(pid); +} + +async function cleanupProject(project: TestProject, backend?: LspBackend): Promise { + try { + await backend?.close(); + } finally { + try { + await terminateChild(project.pidPath); + } finally { + await removeProject(project); + } + } +} + +function nonresponsiveScript(pidPath: string, initializePath: string, exitPath: string): string { + return ` + import { renameSync, writeFileSync } from 'node:fs'; + const writeMarker = (path, value) => { + const temporary = path + '.' + process.pid + '.tmp'; + writeFileSync(temporary, value); + renameSync(temporary, path); + }; + writeMarker(${JSON.stringify(pidPath)}, String(process.pid)); + let initializeReceived = false; + process.stdin.on('data', () => { + if (!initializeReceived) { + initializeReceived = true; + writeMarker(${JSON.stringify(initializePath)}, 'received'); + } + }); + process.on('exit', () => writeMarker(${JSON.stringify(exitPath)}, String(process.pid))); + process.on('SIGTERM', () => process.exit(0)); + `; +} + +function protocolPeerScript( + pidPath: string, + exitPath: string, + mode: 'hang-definition' | 'apply-edit', + markerPath: string, + sourceUri: string, + spawnCountPath?: string, +): string { + const serverBehavior = mode === 'hang-definition' + ? ` + connection.onRequest('textDocument/definition', () => { + writeMarker(${JSON.stringify(markerPath)}, 'requested'); + return new Promise(() => {}); + }); + ` + : ` + connection.onNotification('initialized', () => { + void connection.sendRequest('workspace/applyEdit', { + label: 'attempt to modify the project source', + edit: { + changes: { + [${JSON.stringify(sourceUri)}]: [ + { + range: { + start: { line: 2, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: ${JSON.stringify('// unauthorized edit\n')}, + }, + ], + }, + }, + }).then( + result => writeMarker(${JSON.stringify(markerPath)}, JSON.stringify(result)), + error => writeMarker(${JSON.stringify(markerPath)}, JSON.stringify({ error: String(error) })), + ); + }); + `; + + return ` + import { appendFileSync, renameSync, writeFileSync } from 'node:fs'; + const writeMarker = (path, value) => { + const temporary = path + '.' + process.pid + '.tmp'; + writeFileSync(temporary, value); + renameSync(temporary, path); + }; + writeMarker(${JSON.stringify(pidPath)}, String(process.pid)); + ${spawnCountPath === undefined ? '' : `appendFileSync(${JSON.stringify(spawnCountPath)}, String(process.pid) + String.fromCharCode(10));`} + process.on('exit', () => writeMarker(${JSON.stringify(exitPath)}, String(process.pid))); + process.on('SIGTERM', () => process.exit(0)); + const { createProtocolConnection } = await import(${JSON.stringify(protocolNodeModuleUrl)}); + const connection = createProtocolConnection(process.stdin, process.stdout); + connection.onRequest('initialize', () => ({ + capabilities: { + textDocumentSync: 1, + definitionProvider: true, + }, + serverInfo: { name: 'LSP process test peer', version: '1' }, + })); + connection.onRequest('shutdown', () => null); + connection.onNotification('exit', () => process.exit(0)); + ${serverBehavior} + connection.listen(); + `; +} + +test('a nonexistent executable rejects during startup', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + try { + const missingExecutable = join(fixture.root, 'does-not-exist'); + await assert.rejects( + createLspBackend( + fixture.project, + new AbortController().signal, + serverOptions(missingExecutable, [], 500), + ), + ); + await assert.rejects(readFile(fixture.pidPath, 'utf8'), { code: 'ENOENT' }); + } finally { + await cleanupProject(fixture); + } +}); + +test('a child that exits during startup is rejected and reaped', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + try { + const script = ` + import { renameSync, writeFileSync } from 'node:fs'; + const temporary = ${JSON.stringify(fixture.pidPath)} + '.' + process.pid + '.tmp'; + writeFileSync(temporary, String(process.pid)); + renameSync(temporary, ${JSON.stringify(fixture.pidPath)}); + process.exit(23); + `; + await assert.rejects( + createLspBackend( + fixture.project, + new AbortController().signal, + serverOptions(process.execPath, nodeArgs(script), 1_000), + ), + ); + assertProcessExited(pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker))); + } finally { + await cleanupProject(fixture); + } +}); + +test('a nonresponsive initialize request times out and kills its child', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const initializePath = join(fixture.root, 'initialize.received'); + const exitPath = join(fixture.root, 'child.exited'); + try { + const startup = createLspBackend( + fixture.project, + new AbortController().signal, + serverOptions(process.execPath, nodeArgs(nonresponsiveScript(fixture.pidPath, initializePath, exitPath)), 300), + ); + const rejectedStartup = assert.rejects(startup, /timeout|timed out|deadline/i); + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(initializePath, value => value === 'received'); + await rejectedStartup; + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture); + } +}); + +test('aborting initialization rejects before timeout and kills the child', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const controller = new AbortController(); + const initializePath = join(fixture.root, 'initialize.received'); + const exitPath = join(fixture.root, 'child.exited'); + try { + const startup = createLspBackend( + fixture.project, + controller.signal, + serverOptions(process.execPath, nodeArgs(nonresponsiveScript(fixture.pidPath, initializePath, exitPath)), 5_000), + ); + const rejectedStartup = assert.rejects(startup, /abort/i); + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(initializePath, value => value === 'received'); + controller.abort(); + await rejectedStartup; + await waitForMarker(exitPath, validPidMarker); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture); + } +}); + +test('a definition timeout kills the peer and leaves the backend terminal', { timeout: 15_000 }, async () => { + const fixture = await makeProject(); + const requestMarker = join(fixture.root, 'definition.requested'); + const exitPath = join(fixture.root, 'child.exited'); + const spawnCountPath = join(fixture.root, 'spawned.pids'); + let backend: LspBackend | undefined; + try { + const activeBackend = await createLspBackend( + fixture.project, + new AbortController().signal, + serverOptions( + process.execPath, + nodeArgs(protocolPeerScript( + fixture.pidPath, + exitPath, + 'hang-definition', + requestMarker, + pathToFileURL(fixture.sourcePath).href, + spawnCountPath, + )), + 1_000, + ), + ); + backend = activeBackend; + const query = activeBackend.definitions({ filepath: 'main.go', line: 3 }); + const rejectedQuery = assert.rejects(query, /timeout|timed out|closed|abort|failed/i); + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + await waitForMarker(requestMarker, value => value.includes('requested')); + await rejectedQuery; + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + + await assert.rejects(activeBackend.definitions({ filepath: 'main.go', line: 3 })); + assert.deepEqual((await readFile(spawnCountPath, 'utf8')).trim().split('\n'), [String(pid)]); + } finally { + await cleanupProject(fixture, backend); + } +}); + +test('workspace/applyEdit is rejected without changing source bytes', { timeout: 10_000 }, async () => { + const fixture = await makeProject(); + const resultPath = join(fixture.root, 'apply-edit.result'); + const exitPath = join(fixture.root, 'child.exited'); + let backend: LspBackend | undefined; + try { + const before = await readFile(fixture.sourcePath); + const activeBackend = await createLspBackend( + fixture.project, + new AbortController().signal, + serverOptions( + process.execPath, + nodeArgs(protocolPeerScript( + fixture.pidPath, + exitPath, + 'apply-edit', + resultPath, + pathToFileURL(fixture.sourcePath).href, + )), + 1_000, + ), + ); + backend = activeBackend; + const pid = pidFromMarker(await waitForMarker(fixture.pidPath, validPidMarker)); + const response = JSON.parse(await waitForMarker(resultPath, validJsonMarker)) as { + readonly applied: boolean; + readonly failureReason?: string; + }; + + assert.deepEqual(response, { applied: false, failureReason: 'Read-only LSP session' }); + assert.deepEqual(await readFile(fixture.sourcePath), before); + await activeBackend.close(); + backend = undefined; + await waitForMarker(exitPath, validPidMarker); + await waitForProcessExit(pid); + assertProcessExited(pid); + } finally { + await cleanupProject(fixture, backend); + } +}); diff --git a/tests/lsp.test.ts b/tests/lsp.test.ts new file mode 100644 index 0000000..2306bf5 --- /dev/null +++ b/tests/lsp.test.ts @@ -0,0 +1,677 @@ +import assert from 'node:assert/strict'; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { test } from 'node:test'; + +import { createProject } from '../dist/adapter/filesystem/project.js'; +import { createLspBackend } from '../dist/adapter/lsp/backend.js'; +import { createRunLsp } from '../dist/app/lsp.js'; +import type { Hover, LspBackend, LspCall, Project } from '../dist/index.js'; + +const project = { root: '/repo' } as Project; +const target = { filepath: 'src/main.go', line: 1 }; +const LSP_FIXTURE = fileURLToPath(new URL('./fixtures/lsp/', import.meta.url)); +const protocolNodeModuleUrl = import.meta.resolve('vscode-languageserver-protocol/node'); + +function backend(overrides: Partial = {}): LspBackend { + return { + definitions: async () => [], + declarations: async () => [], + typeDefinitions: async () => [], + implementations: async () => [], + references: async () => [], + hover: async () => null, + documentSymbols: async () => [], + workspaceSymbols: async () => [], + callers: async () => [], + callees: async () => [], + close: async () => undefined, + ...overrides, + }; +} + +test('unused and depth-zero LSP facades do not start a backend', async () => { + let starts = 0; + const facade = createRunLsp(project, async () => { + starts += 1; + return backend(); + }); + + assert.deepEqual(await facade.tools.callers(target, { depth: 0 }), []); + await assert.rejects( + facade.tools.callers({ filepath: target.filepath, line: 0 }, { depth: 0 }), + /positive safe integer/, + ); + assert.deepEqual(await facade.tools.workspaceSymbols(''), []); + for (const depth of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + await assert.rejects(facade.tools.callers(target, { depth }), /nonnegative safe integer/); + } + assert.equal(starts, 0); + const pending = facade.tools.definitions(target); + await facade.close(); + await assert.rejects(pending, /closed/i); + assert.equal(starts, 0); +}); + +test('validation failures are observed when callers discard the returned promises', async () => { + const facade = createRunLsp(project, async () => backend()); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + try { + void facade.tools.definitions({ filepath: target.filepath, line: 0 }); + void facade.tools.callers(target, { depth: -1 }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(unhandled, []); + } finally { + process.off('unhandledRejection', onUnhandled); + await facade.close(); + } +}); + +test('concurrent helpers share one lazy backend', async () => { + let starts = 0; + const requests: string[] = []; + const facade = createRunLsp(project, async () => { + starts += 1; + return backend({ + definitions: async () => { + requests.push('definition'); + return []; + }, + hover: async () => { + requests.push('hover'); + return null; + }, + }); + }); + + const [definitions, hover] = await Promise.all([ + facade.tools.definitions(target), + facade.tools.hover(target), + ]); + + assert.deepEqual(definitions, []); + assert.equal(hover, null); + assert.equal(starts, 1); + assert.deepEqual(requests.sort(), ['definition', 'hover']); + await facade.close(); +}); + +test('missing and failed factories retain their first startup failure', async () => { + const unconfigured = createRunLsp(project); + await assert.rejects(unconfigured.tools.definitions(target), /LSP is not configured for this run/); + await assert.rejects(unconfigured.tools.definitions(target), /LSP is not configured for this run/); + await unconfigured.close(); + + let starts = 0; + const failure = new Error('startup failed'); + const failed = createRunLsp(project, async () => { + starts += 1; + throw failure; + }); + await assert.rejects(failed.tools.hover(target), error => error === failure); + await assert.rejects(failed.tools.hover(target), error => error === failure); + assert.equal(starts, 1); + await failed.close(); +}); + +test('closing an unused facade rejects later calls', async () => { + const facade = createRunLsp(project, async () => backend()); + await facade.close(); + await assert.rejects(facade.tools.definitions(target), /closed/i); + await assert.rejects(facade.tools.callers(target, { depth: 0 }), /closed/i); + await assert.rejects(facade.tools.workspaceSymbols(''), /closed/i); +}); + +test('a backend that resolves after close is closed immediately', async () => { + const factoryStarted = Promise.withResolvers(); + const factoryResult = Promise.withResolvers(); + let closeCalls = 0; + const facade = createRunLsp(project, async () => { + factoryStarted.resolve(); + return factoryResult.promise; + }); + + const pending = facade.tools.definitions(target); + await factoryStarted.promise; + const closing = facade.close(); + factoryResult.resolve(backend({ close: async () => { closeCalls += 1; } })); + await assert.rejects(pending, /closed/i); + await closing; + assert.equal(closeCalls, 1); +}); + +test('close aborts active work, waits for it, and closes the backend once', async () => { + let closeCalls = 0; + let signal: AbortSignal | undefined; + const requestStarted = Promise.withResolvers(); + const facade = createRunLsp(project, async (_project, runSignal) => { + signal = runSignal; + return backend({ + hover: () => { + const pending = Promise.withResolvers(); + runSignal.addEventListener('abort', () => pending.reject(new Error('aborted')), { once: true }); + requestStarted.resolve(); + return pending.promise; + }, + close: async () => { + closeCalls += 1; + }, + }); + }); + + const pending = facade.tools.hover(target); + await requestStarted.promise; + const firstClose = facade.close(); + const secondClose = facade.close(); + await assert.rejects(pending, /abort/i); + await Promise.all([firstClose, secondClose]); + assert.equal(signal?.aborted, true); + assert.equal(closeCalls, 1); +}); +interface ExpectedCall { + readonly filepath: string; + readonly source: string; + readonly lines: { readonly start: number; readonly end: number }; + readonly depth: number; +} + +interface LspGoldens { + readonly seedCalleesDepth2: readonly ExpectedCall[]; + readonly firstCallCalleesDepth1: readonly ExpectedCall[]; + readonly seedCalleesWithDependenciesDepth2: readonly ExpectedCall[]; + readonly seedCallersDepth2: readonly ExpectedCall[]; + readonly cycleACalleesDepth3: readonly ExpectedCall[]; + readonly workerRunCalleesDepth2: readonly ExpectedCall[]; + readonly initializerCallersDepth2: readonly ExpectedCall[]; + readonly unicodeFirstCalleesDepth1: readonly ExpectedCall[]; + readonly unicodeFirstCallCallerDepth1: readonly ExpectedCall[]; +} + +async function copyLspFixture(crlfUnicode = false): Promise<{ + readonly workspace: string; + readonly projectRoot: string; + readonly dependencyRoot: string; +}> { + const workspace = await mkdtemp(join(tmpdir(), 'rulekeeper lsp-')); + const projectRoot = join(workspace, 'project'); + const dependencyRoot = join(workspace, 'project-dependency'); + await cp(join(LSP_FIXTURE, 'project'), projectRoot, { recursive: true }); + await cp(join(LSP_FIXTURE, 'project-dependency'), dependencyRoot, { recursive: true }); + if (crlfUnicode) { + const unicodeFile = join(projectRoot, 'unicode.go'); + const source = await readFile(unicodeFile, 'utf8'); + await writeFile(unicodeFile, source.replace(/\n/g, '\r\n')); + } + return { workspace, projectRoot, dependencyRoot }; +} + +async function snapshotFiles(root: string): Promise> { + const files = new Map(); + const visit = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filepath = join(directory, entry.name); + if (entry.isDirectory()) await visit(filepath); + else if (entry.isFile()) files.set(relative(root, filepath), (await readFile(filepath)).toString('base64')); + } + }; + await visit(root); + return files; +} + +function normalizeCallPath(filepath: string, dependencyRoot: string): string { + if (!isAbsolute(filepath)) return filepath; + const withinDependency = relative(dependencyRoot, filepath); + if ( + withinDependency === '' || + (withinDependency !== '..' && !withinDependency.startsWith(`..${sep}`) && !isAbsolute(withinDependency)) + ) { + return `${withinDependency === '' ? '' : `/${withinDependency.split(sep).join('/')}`}`; + } + return filepath; +} + +function graphView(calls: readonly LspCall[], dependencyRoot: string): readonly ExpectedCall[] { + return calls.map(call => ({ + filepath: normalizeCallPath(call.filepath, dependencyRoot), + source: call.source, + lines: call.lines, + depth: call.depth, + })); +} + +async function readLspGoldens(): Promise { + return JSON.parse(await readFile(join(LSP_FIXTURE, 'expected.json'), 'utf8')) as LspGoldens; +} + +function hoverText(hover: Hover | null): string { + const flatten = (value: unknown): string => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flatten).join('\n'); + if (typeof value === 'object' && value !== null && 'value' in value) { + return typeof value.value === 'string' ? value.value : ''; + } + return ''; + }; + return hover === null ? '' : flatten(hover.contents); +} + +interface CallHierarchyPeerOptions { + readonly documentSymbols: boolean; + readonly itemHasFullRange: boolean; + readonly languageId: string; +} + +async function withCallHierarchyPeer( + options: CallHierarchyPeerOptions, + action: (backend: LspBackend, documentSymbolMarker: string) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'rulekeeper lsp hierarchy-')); + const sourcePath = join(root, 'main.go'); + const documentSymbolMarker = join(root, 'document-symbol.requested'); + const source = 'package fixture\n\nfunc Target() {\n Caller()\n}\n\nfunc Caller() {}\n\nfunc Parent() {\n Target()\n}\n'; + await writeFile(sourcePath, source); + + const uri = pathToFileURL(sourcePath).href; + const callerSelectionRange = { + start: { line: 6, character: 5 }, + end: { line: 6, character: 11 }, + }; + const callerFullRange = { + start: { line: 6, character: 0 }, + end: { line: 6, character: 'func Caller() {}'.length }, + }; + const peer = { + rootItem: { + name: 'Target', + kind: 12, + uri, + range: { start: { line: 2, character: 0 }, end: { line: 4, character: 1 } }, + selectionRange: { start: { line: 2, character: 5 }, end: { line: 2, character: 11 } }, + }, + callerItem: { + name: 'Caller', + kind: 12, + uri, + range: options.itemHasFullRange ? callerFullRange : callerSelectionRange, + selectionRange: callerSelectionRange, + }, + flatSymbol: { + name: 'Caller', + kind: 12, + location: { uri, range: callerSelectionRange }, + }, + parentItem: { + name: 'Parent', + kind: 12, + uri, + range: { start: { line: 8, character: 5 }, end: { line: 8, character: 11 } }, + selectionRange: { start: { line: 8, character: 5 }, end: { line: 8, character: 11 } }, + }, + parentCallRange: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, + callRange: { start: { line: 3, character: 4 }, end: { line: 3, character: 10 } }, + }; + const capabilities: Record = { callHierarchyProvider: true }; + if (options.documentSymbols) capabilities.documentSymbolProvider = true; + const serverScript = ` + import { writeFileSync } from 'node:fs'; + // This child process must load the protocol package independently of the test runner. + const { createProtocolConnection } = await import(${JSON.stringify(protocolNodeModuleUrl)}); + const peer = ${JSON.stringify(peer)}; + const connection = createProtocolConnection(process.stdin, process.stdout); + connection.onRequest('initialize', () => ({ capabilities: ${JSON.stringify(capabilities)} })); + connection.onRequest('textDocument/prepareCallHierarchy', () => [peer.rootItem]); + connection.onRequest('callHierarchy/outgoingCalls', ({ item }) => + item.name === 'Target' ? [{ to: peer.callerItem, fromRanges: [peer.callRange] }] : [], + ); + connection.onRequest('callHierarchy/incomingCalls', ({ item }) => + item.name === 'Target' ? [{ from: peer.parentItem, fromRanges: [peer.parentCallRange] }] : [], + ); + connection.onRequest('textDocument/documentSymbol', () => { + writeFileSync(${JSON.stringify(documentSymbolMarker)}, 'requested'); + return [peer.flatSymbol]; + }); + connection.onRequest('shutdown', () => null); + connection.onNotification('exit', () => process.exit(0)); + connection.listen(); + `; + + try { + const lspBackend = await createLspBackend( + createProject(root), + new AbortController().signal, + { + command: process.execPath, + args: ['--input-type=module', '-e', serverScript], + languageId: options.languageId, + requestTimeoutMs: 5_000, + }, + ); + try { + await action(lspBackend, documentSymbolMarker); + } finally { + await lspBackend.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('call hierarchy uses its full range when document symbols are unsupported', async () => { + await withCallHierarchyPeer( + { documentSymbols: false, itemHasFullRange: true, languageId: 'go' }, + async (lspBackend, documentSymbolMarker) => { + const calls = await lspBackend.callees( + { filepath: 'main.go', line: 3, column: 6 }, + { depth: 1 }, + ); + assert.deepEqual(calls.map(call => ({ name: call.name, source: call.source })), [ + { name: 'Caller', source: 'func Caller() {}' }, + ]); + await assert.rejects(readFile(documentSymbolMarker, 'utf8'), { code: 'ENOENT' }); + }, + ); +}); + +test('flat symbol locations never become declaration source', async () => { + await withCallHierarchyPeer( + { documentSymbols: true, itemHasFullRange: false, languageId: 'go' }, + async (lspBackend, documentSymbolMarker) => { + const calls = await lspBackend.callees( + { filepath: 'main.go', line: 3, column: 6 }, + { depth: 1 }, + ); + assert.deepEqual(calls.map(call => ({ name: call.name, source: call.source })), [ + { name: 'Caller', source: 'func Caller() {}' }, + ]); + assert.equal(await readFile(documentSymbolMarker, 'utf8'), 'requested'); + }, + ); + + await withCallHierarchyPeer( + { documentSymbols: true, itemHasFullRange: false, languageId: 'typescript' }, + async lspBackend => { + await assert.rejects( + lspBackend.callees({ filepath: 'main.go', line: 3, column: 6 }, { depth: 1 }), + /Could not find the full declaration/, + ); + }, + ); +}); + +test('line-only Go graph targets use AST extents with flat identifier-only symbols', async () => { + await withCallHierarchyPeer( + { documentSymbols: true, itemHasFullRange: false, languageId: 'go' }, + async (lspBackend, documentSymbolMarker) => { + const target = { filepath: 'main.go', line: 4 }; + const [callers, callees] = await Promise.all([ + lspBackend.callers(target, { depth: 1 }), + lspBackend.callees(target, { depth: 1 }), + ]); + assert.deepEqual(callers.map(({ name, source }) => ({ name, source })), [ + { name: 'Parent', source: 'func Parent() {\n Target()\n}' }, + ]); + assert.deepEqual(callees.map(({ name, source }) => ({ name, source })), [ + { name: 'Caller', source: 'func Caller() {}' }, + ]); + assert.equal(await readFile(documentSymbolMarker, 'utf8'), 'requested'); + }, + ); +}); + +test('line-only Go interface method graph targets the method name', async () => { + const root = await mkdtemp(join(tmpdir(), 'rulekeeper lsp interface method-')); + const sourcePath = join(root, 'main.go'); + const preparedMarker = join(root, 'prepared-position.json'); + const source = 'package fixture\n\ntype Runner interface {\n Run()\n}\n\nfunc Caller() {}\n'; + await writeFile(sourcePath, source); + + const uri = pathToFileURL(sourcePath).href; + const methodRange = { + start: { line: 3, character: 4 }, + end: { line: 3, character: 7 }, + }; + const callerItem = { + name: 'Caller', + kind: 12, + uri, + range: { + start: { line: 6, character: 0 }, + end: { line: 6, character: 'func Caller() {}'.length }, + }, + selectionRange: { + start: { line: 6, character: 5 }, + end: { line: 6, character: 11 }, + }, + }; + const serverScript = ` + import { writeFileSync } from 'node:fs'; + import { createProtocolConnection } from ${JSON.stringify(protocolNodeModuleUrl)}; + const connection = createProtocolConnection(process.stdin, process.stdout); + const uri = ${JSON.stringify(uri)}; + const rootItem = { + name: 'Run', + kind: 6, + uri, + range: ${JSON.stringify(methodRange)}, + selectionRange: ${JSON.stringify(methodRange)}, + }; + const callerItem = ${JSON.stringify(callerItem)}; + connection.onRequest('initialize', () => ({ + capabilities: { callHierarchyProvider: true, documentSymbolProvider: true }, + })); + connection.onRequest('textDocument/prepareCallHierarchy', ({ position }) => { + writeFileSync( + ${JSON.stringify(preparedMarker)}, + JSON.stringify(position), + ); + return position.line === 3 && position.character === 4 ? [rootItem] : []; + }); + connection.onRequest('callHierarchy/outgoingCalls', ({ item }) => + item.name === 'Run' ? [{ + to: callerItem, + fromRanges: [{ start: { line: 3, character: 4 }, end: { line: 3, character: 7 } }], + }] : [], + ); + connection.onRequest('callHierarchy/incomingCalls', () => []); + connection.onRequest('textDocument/documentSymbol', () => [{ + name: 'Run', + kind: 6, + location: { uri, range: ${JSON.stringify(methodRange)} }, + }]); + connection.onRequest('shutdown', () => null); + connection.onNotification('exit', () => process.exit(0)); + connection.listen(); + `; + + try { + const lspBackend = await createLspBackend( + createProject(root), + new AbortController().signal, + { + command: process.execPath, + args: ['--input-type=module', '-e', serverScript], + languageId: 'go', + requestTimeoutMs: 5_000, + }, + ); + try { + const calls = await lspBackend.callees({ filepath: 'main.go', line: 4 }, { depth: 1 }); + assert.equal(await readFile(preparedMarker, 'utf8'), JSON.stringify({ line: 3, character: 4 })); + assert.ok(calls.length > 0); + assert.deepEqual(calls.map(({ name, source }) => ({ name, source })), [ + { name: 'Caller', source: 'func Caller() {}' }, + ]); + } finally { + await lspBackend.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('real gopls navigation and call hierarchy match the Go fixture goldens', { timeout: 180_000 }, async () => { + const fixture = await copyLspFixture(true); + const projectFilesBefore = await snapshotFiles(fixture.projectRoot); + const dependencyFilesBefore = await snapshotFiles(fixture.dependencyRoot); + const lspProject = createProject(fixture.projectRoot); + const env: NodeJS.ProcessEnv = { + ...process.env, + GOPROXY: 'off', + GOWORK: 'off', + GOTOOLCHAIN: 'local', + GOTELEMETRY: 'off', + GOCACHE: join(fixture.workspace, 'go-build-cache'), + GOMODCACHE: join(fixture.workspace, 'go-module-cache'), + }; + let starts = 0; + const facade = createRunLsp(lspProject, (_project, signal) => { + starts += 1; + return createLspBackend(lspProject, signal, { + command: 'gopls', + args: ['serve'], + languageId: 'go', + env, + }); + }); + try { + const golden = await readLspGoldens(); + const seedTarget = { filepath: 'seed.go', line: 9 }; + const [localCallees, dependencyCallees] = await Promise.all([ + facade.tools.callees(seedTarget, { depth: 2 }), + facade.tools.callees(seedTarget, { depth: 2, includeDependencies: true }), + ]); + assert.deepEqual(graphView(localCallees, fixture.dependencyRoot), golden.seedCalleesDepth2); + assert.deepEqual( + graphView(dependencyCallees, fixture.dependencyRoot), + golden.seedCalleesWithDependenciesDepth2, + ); + assert.ok(!graphView(localCallees, fixture.dependencyRoot).some(call => call.filepath.startsWith(''))); + assert.ok(!dependencyCallees.some(call => call.source === 'func Deep() {}')); + + assert.deepEqual( + graphView(await facade.tools.callers({ filepath: 'seed.go', line: 8 }, { depth: 2 }), fixture.dependencyRoot), + golden.seedCallersDepth2, + ); + assert.deepEqual( + graphView( + await facade.tools.callees({ filepath: 'seed.go', line: 9, column: 2 }, { depth: 1 }), + fixture.dependencyRoot, + ), + golden.firstCallCalleesDepth1, + ); + assert.deepEqual( + graphView(await facade.tools.callees({ filepath: 'seed.go', line: 44 }, { depth: 3 }), fixture.dependencyRoot), + golden.cycleACalleesDepth3, + ); + assert.deepEqual( + graphView(await facade.tools.callees({ filepath: 'worker.go', line: 10 }, { depth: 2 }), fixture.dependencyRoot), + golden.workerRunCalleesDepth2, + ); + assert.deepEqual( + graphView(await facade.tools.callers({ filepath: 'initializers.go', line: 7 }, { depth: 2 }), fixture.dependencyRoot), + golden.initializerCallersDepth2, + ); + + const unicodeSource = await readFile(join(fixture.projectRoot, 'unicode.go'), 'utf8'); + const unicodeColumn = unicodeSource.split(/\r?\n/u)[2]!.indexOf('First') + 1; + const unicodeDefinition = await facade.tools.definitions({ + filepath: 'unicode.go', + line: 3, + column: unicodeColumn, + }); + assert.ok(unicodeDefinition.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'seed.go'), + )); + assert.deepEqual( + graphView(await facade.tools.callees({ filepath: 'unicode.go', line: 3 }, { depth: 1 }), fixture.dependencyRoot), + golden.unicodeFirstCalleesDepth1, + ); + assert.deepEqual( + graphView( + await facade.tools.callers({ filepath: 'unicode.go', line: 3, column: unicodeColumn }, { depth: 1 }), + fixture.dependencyRoot, + ), + golden.unicodeFirstCallCallerDepth1, + ); + assert.deepEqual(await facade.tools.callees({ filepath: 'seed.go', line: 1 }, { depth: 1 }), []); + + const firstDefinition = await facade.tools.definitions({ filepath: 'seed.go', line: 9, column: 2 }); + assert.ok(firstDefinition.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'seed.go') && + location.range.start.line === 15, + )); + const absoluteDefinition = await facade.tools.definitions({ + filepath: join(fixture.projectRoot, 'seed.go'), + line: 9, + column: 2, + }); + assert.ok(absoluteDefinition.some(location => fileURLToPath(location.uri) === join(fixture.projectRoot, 'seed.go'))); + await assert.rejects( + facade.tools.definitions({ filepath: join(fixture.dependencyRoot, 'dep.go'), line: 4, column: 2 }), + /outside|dependency/i, + ); + const externalDefinition = await facade.tools.definitions( + { filepath: join(fixture.dependencyRoot, 'dep.go'), line: 4, column: 2 }, + { includeDependencies: true }, + ); + assert.ok(externalDefinition.some(location => fileURLToPath(location.uri) === join(fixture.dependencyRoot, 'dep.go'))); + const defaultReferences = await facade.tools.references({ filepath: 'seed.go', line: 8, column: 6 }); + assert.ok(defaultReferences.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'seed.go') && + location.range.start.line === 32, + )); + assert.ok(!defaultReferences.some(location => location.range.start.line === 7)); + const declarationReferences = await facade.tools.references( + { filepath: 'seed.go', line: 8, column: 6 }, + { includeDeclaration: true }, + ); + assert.ok(declarationReferences.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'seed.go') && + location.range.start.line === 7, + )); + const workerType = await facade.tools.typeDefinitions({ filepath: 'worker.go', line: 15, column: 2 }); + assert.ok(workerType.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'worker.go') && + location.range.start.line === 6, + )); + const runnerImplementations = await facade.tools.implementations({ filepath: 'worker.go', line: 3, column: 6 }); + assert.ok(runnerImplementations.some(location => + fileURLToPath(location.uri) === join(fixture.projectRoot, 'worker.go') && + location.range.start.line === 6, + )); + assert.match(hoverText(await facade.tools.hover({ filepath: 'seed.go', line: 8, column: 6 })), /func Seed\(\)/); + assert.equal(await facade.tools.hover({ filepath: 'seed.go', line: 2 }), null); + + const documentSymbols = await facade.tools.documentSymbols('seed.go'); + assert.ok(documentSymbols.some(symbol => symbol.name === 'Seed')); + assert.ok(documentSymbols.some(symbol => symbol.name === 'CycleA')); + const workspaceSymbols = await facade.tools.workspaceSymbols('Grandcaller'); + assert.ok(workspaceSymbols.some(symbol => symbol.name === 'Grandcaller')); + assert.deepEqual(await facade.tools.workspaceSymbols('NoSuchLspFixtureSymbol'), []); + + await assert.rejects( + facade.tools.declarations({ filepath: 'seed.go', line: 9, column: 2 }), + /textDocument\/declaration/, + ); + assert.ok((await facade.tools.definitions({ filepath: 'seed.go', line: 9, column: 2 })).length > 0); + await assert.rejects(facade.tools.definitions({ filepath: 'missing.go', line: 1 }), /missing\.go|could not be read/i); + await assert.rejects(facade.tools.definitions({ filepath: 'seed.go', line: 10_000 }), /position|coordinate|line/i); + await assert.rejects(facade.tools.definitions({ filepath: 'seed.go', line: 9, column: 10_000 }), /position|coordinate|column/i); + assert.equal(starts, 1); + } finally { + try { + await facade.close(); + } finally { + try { + assert.deepEqual(await snapshotFiles(fixture.projectRoot), projectFilesBefore); + assert.deepEqual(await snapshotFiles(fixture.dependencyRoot), dependencyFilesBefore); + } finally { + await rm(fixture.workspace, { recursive: true, force: true }); + } + } + } +}); diff --git a/tests/rule-resolution.test.ts b/tests/rule-resolution.test.ts index 4d86d06..f48f0cf 100644 --- a/tests/rule-resolution.test.ts +++ b/tests/rule-resolution.test.ts @@ -6,9 +6,11 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { after, before, test } from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { Worker } from 'node:worker_threads'; +import { MessageChannel, Worker } from 'node:worker_threads'; import type { LoadedRule, RuleSelection } from '../dist/app/ports.js'; +import { createProject } from '../dist/adapter/filesystem/project.js'; +import { createRuleTestLspHost } from '../dist/adapter/filesystem/rule-test-lsp.js'; import { createRuleDependencyManager, type PreparedRuleDependencies, type RuleDependencyManager } from '../dist/adapter/filesystem/dependencies.js'; import { getRuleDependencies } from '../dist/adapter/filesystem/rule-resolution.js'; import { createRuleLoader } from '../dist/adapter/filesystem/rules.js'; @@ -707,23 +709,42 @@ function startWorker(prelude = ''): Worker { return new Worker(new URL(`data:text/javascript,${encodeURIComponent(`${prelude}\nawait import(${JSON.stringify(module)});`)}`)); } -async function requestWorker(worker: Worker, request: RuleTestWorkerRequest): Promise { +async function requestWorker( + worker: Worker, + request: Omit, +): Promise { const result = Promise.withResolvers(); const onMessage = (message: RuleTestWorkerMessage) => { if (message.id === request.id) result.resolve(message); }; const onError = (error: Error) => result.reject(error); const onExit = (code: number) => result.reject(new Error(`Worker exited before a result: ${code}`)); + const channel = new MessageChannel(); + const host = createRuleTestLspHost( + channel.port1, + createProject(request.root, { excludeFiles: [request.ruleFile] }), + ); worker.on('message', onMessage); worker.on('error', onError); worker.on('exit', onExit); + let transferred = false; try { - worker.postMessage(request); + worker.postMessage( + { ...request, lspPort: channel.port2 } satisfies RuleTestWorkerRequest, + [channel.port2], + ); + transferred = true; return await result.promise; } finally { worker.off('message', onMessage); worker.off('error', onError); worker.off('exit', onExit); + try { + await host.close(); + } finally { + channel.port1.close(); + if (!transferred) channel.port2.close(); + } } } diff --git a/tests/rule-test-pool.test.ts b/tests/rule-test-pool.test.ts index 065271b..0db463c 100644 --- a/tests/rule-test-pool.test.ts +++ b/tests/rule-test-pool.test.ts @@ -5,8 +5,57 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { after, test } from 'node:test'; +import type { LspBackend, LspBackendFactory } from '../dist/app/ports.js'; +import type { Hover } from '../dist/app/rule.js'; +import { createRuleLoader } from '../dist/adapter/filesystem/rules.js'; +import { runRuleTests } from '../dist/adapter/filesystem/rule-tests.js'; import { CLI, createSandbox, runCli, writeFile } from './support.ts'; +function lspBackend(close: () => void, hover: () => Promise): LspBackend { + return { + definitions: async () => [], + declarations: async () => [], + typeDefinitions: async () => [], + implementations: async () => [], + references: async () => [], + hover, + documentSymbols: async () => [], + workspaceSymbols: async () => [], + callers: async () => [], + callees: async () => [], + close: async () => { close(); }, + }; +} + +async function loadInlineRule(projectRoot: string, ruleFile: string, source: string) { + writeFile(ruleFile, source); + const rules = await createRuleLoader().load({ + root: projectRoot, + explicit: ruleFile, + invocationCwd: projectRoot, + }); + assert.equal(rules.length, 1); + return rules; +} + +function inlineRule(check: string, tests: string, extraImports = ''): string { + return `import { Rule, pass, test } from '@alex-dbs/coderuler'; +${extraImports} +export default class InlineLsp extends Rule { + files = ['inputs/*.txt']; + check = ${check}; +} +export const tests = [${tests}];`; +} + +function hoverUntilAbort(signal: AbortSignal): Promise { + const request = Promise.withResolvers(); + const rejectOnAbort = () => request.reject(new Error('fake LSP query aborted')); + if (signal.aborted) rejectOnAbort(); + else signal.addEventListener('abort', rejectOnAbort, { once: true }); + return request.promise; +} + const sandbox = createSandbox('coderuler-test-pool-'); after(() => sandbox.cleanup()); @@ -389,6 +438,212 @@ export const tests = [${cases(names.slice(2))}];`); for (const record of records) assert.equal(existsSync(record.root), false, record.root); }); +test('inline rule tests close one fresh parent-owned LSP backend per successful case', { timeout: 30_000 }, async () => { + const target = sandbox.isolated('parent-lsp-success'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + cases(['first', 'second']), + ), + ); + const openedRoots: string[] = []; + const closedRoots: string[] = []; + const rootsAtClose: boolean[] = []; + const createBackend: LspBackendFactory = async project => { + openedRoots.push(project.root); + return lspBackend(() => { + closedRoots.push(project.root); + rootsAtClose.push(existsSync(project.root)); + }, async () => null); + }; + + const results = await runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend); + assert.deepEqual(results.map(result => result.actual), ['pass', 'pass']); + assert.equal(openedRoots.length, 2); + assert.equal(new Set(openedRoots).size, 2, 'each reused-worker case must get its own backend'); + assert.deepEqual(closedRoots, openedRoots); + assert.deepEqual(rootsAtClose, [true, true], 'the parent backend must close before its case root is removed'); + for (const root of openedRoots) assert.equal(existsSync(root), false, root); +}); + +test('inline rule timeout closes a pending parent-owned LSP backend before removing its root', { timeout: 20_000 }, async () => { + const target = sandbox.isolated('parent-lsp-timeout'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + cases(['pending']), + ), + ); + const started = Promise.withResolvers(); + const closedRoots: string[] = []; + const rootsAtClose: boolean[] = []; + const createBackend: LspBackendFactory = async (project, signal) => { + started.resolve(project.root); + return lspBackend(() => { + closedRoots.push(project.root); + rootsAtClose.push(existsSync(project.root)); + }, () => hoverUntilAbort(signal)); + }; + // This exercises the real worker-thread deadline, which cannot be driven by parent-side fake time. + const execution = runRuleTests(rules, undefined, 4_000, 1, false, false, createBackend); + const startedOrFinished = await Promise.race([ + started.promise.then(root => ({ root })), + execution.then(() => ({ finished: true }), error => ({ error })), + ]); + assert.ok('root' in startedOrFinished, 'the case must reach its parent-owned LSP query before timeout'); + const [result] = await execution; + assert.equal(result?.actual, 'timeout'); + assert.deepEqual(closedRoots, [startedOrFinished.root]); + assert.deepEqual(rootsAtClose, [true]); + assert.equal(existsSync(startedOrFinished.root), false); +}); + +test('inline sibling failure closes a pending parent-owned LSP backend before removing its root', { timeout: 20_000 }, async context => { + const target = sandbox.isolated('parent-lsp-sibling-failure'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const gate = await startGate(); + context.after(() => gate.close()); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (subject, context) => { + if (subject.source === 'sibling') { + await waitFor('lsp-started'); + throw new Error('sibling operational failure'); + } + await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + return pass(); + }`, + `test('pending').withFiles({ 'inputs/one.txt': 'pending' }).passes(), +test('sibling').withFiles({ 'inputs/one.txt': 'sibling' }).passes()`, + gateHelpers(gate.url), + ), + ); + const started = Promise.withResolvers(); + const closedRoots: string[] = []; + const rootsAtClose: boolean[] = []; + const createBackend: LspBackendFactory = async (project, signal) => { + started.resolve(project.root); + return lspBackend(() => { + closedRoots.push(project.root); + rootsAtClose.push(existsSync(project.root)); + }, () => hoverUntilAbort(signal)); + }; + const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend); + const startedOrFinished = await Promise.race([ + started.promise.then(root => ({ root })), + execution.then(() => ({ finished: true }), error => ({ error })), + ]); + assert.ok('root' in startedOrFinished, 'the pending sibling must start its LSP query before failure'); + gate.signal('lsp-started'); + await assert.rejects(execution, /sibling operational failure/); + assert.deepEqual(closedRoots, [startedOrFinished.root]); + assert.deepEqual(rootsAtClose, [true]); + assert.equal(existsSync(startedOrFinished.root), false); +}); + +test('inline sibling LSP cleanup failure preserves the first rule failure', { timeout: 20_000 }, async context => { + const target = sandbox.isolated('parent-lsp-sibling-cleanup-precedence'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const gate = await startGate(); + context.after(() => gate.close()); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (subject, context) => { + if (subject.source === 'failure') { + await waitFor('lsp-started'); + throw new Error('first rule failure'); + } + await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + return pass(); + }`, + `test('pending').withFiles({ 'inputs/one.txt': 'pending' }).passes(), +test('failure').withFiles({ 'inputs/one.txt': 'failure' }).passes()`, + gateHelpers(gate.url), + ), + ); + const started = Promise.withResolvers(); + const closedRoots: string[] = []; + const createBackend: LspBackendFactory = async (project, signal) => lspBackend( + () => { + closedRoots.push(project.root); + throw new Error('sibling LSP cleanup failure'); + }, + () => { + started.resolve(project.root); + return hoverUntilAbort(signal); + }, + ); + const execution = runRuleTests(rules, undefined, 15_000, 2, false, false, createBackend); + const startedOrFinished = await Promise.race([ + started.promise.then(root => ({ root })), + execution.then(() => ({ finished: true }), error => ({ error })), + ]); + assert.ok('root' in startedOrFinished, 'the pending sibling must start its LSP query before failure'); + gate.signal('lsp-started'); + await assert.rejects(execution, error => { + const message = error instanceof Error ? error.message : String(error); + assert.match(message, /first rule failure/); + assert.doesNotMatch(message, /sibling LSP cleanup failure/); + return true; + }); + assert.deepEqual(closedRoots, [startedOrFinished.root]); + assert.equal(existsSync(startedOrFinished.root), false); +}); + +test('inline rule worker failure takes precedence over parent-owned LSP cleanup failure', { timeout: 20_000 }, async () => { + const target = sandbox.isolated('parent-lsp-cleanup-precedence'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); throw new Error('original worker failure'); }`, + cases(['failure']), + ), + ); + const createBackend: LspBackendFactory = async () => lspBackend( + () => { throw new Error('LSP host cleanup failure'); }, + async () => null, + ); + + await assert.rejects( + runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend), + /original worker failure/, + ); +}); + +test('inline rule test reports a standalone parent-owned LSP cleanup failure', { timeout: 20_000 }, async () => { + const target = sandbox.isolated('parent-lsp-cleanup-standalone'); + const ruleFile = join(target.cwd, '.coderuler', 'lsp.rule.ts'); + const rules = await loadInlineRule( + target.cwd, + ruleFile, + inlineRule( + `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, + cases(['success']), + ), + ); + const createBackend: LspBackendFactory = async () => lspBackend( + () => { throw new Error('standalone LSP cleanup failure'); }, + async () => null, + ); + + await assert.rejects( + runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend), + /standalone LSP cleanup failure/, + ); +}); + test('test rejects concurrency values that are not positive safe integers', async context => { const target = sandbox.isolated('invalid-concurrency'); writeFile(join(target.cwd, '.coderuler/text.rule.ts'), `import { Rule, pass, test } from '@alex-dbs/coderuler'; diff --git a/tests/structured-scopes.test.ts b/tests/structured-scopes.test.ts index c3c886c..e2f3540 100644 --- a/tests/structured-scopes.test.ts +++ b/tests/structured-scopes.test.ts @@ -8,6 +8,7 @@ import { json, parseJson } from '../dist/adapter/scope/json.js'; import { yaml, parseYaml } from '../dist/adapter/scope/yaml.js'; import { wholeFileScope } from '../dist/app/selection.js'; import type { ProjectContext, ScopeMatch, ScopeSelector, SourceFile } from '../dist/index.js'; +import { createRunLsp } from '../dist/app/lsp.js'; import { ROOT } from './support.ts'; interface LineSpan { @@ -56,7 +57,8 @@ function scope(id: string, source: string, lines: LineSpan, data?: unknown): Rec return { id, source, data, lines }; } -const projectContext: ProjectContext = { project: createProject(ROOT), trace: () => {} }; +const project = createProject(ROOT); +const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, trace: () => {} }; function collect( selector: ScopeSelector, source: string, diff --git a/website/src/content/docs/writing-rules.md b/website/src/content/docs/writing-rules.md index 43b6f1f..2d39605 100644 --- a/website/src/content/docs/writing-rules.md +++ b/website/src/content/docs/writing-rules.md @@ -126,6 +126,41 @@ A custom function receives the selected subject and a context object. Return `pa The hooks receive `project` readers and `trace(message, details?)`. `select` also receives `context.select(...)`. Checks also receive `ruleName` and `context.ai(...)`. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. LSP use is optional; install Go and `gopls` only if a rule calls these helpers. + +Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries use the enclosing named function or method when the column is omitted. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`. + +Caller and callee results are a static call graph. They can omit dynamic calls and do not load every possible package consumer. Use them as the server's known call relationships, not as a complete runtime graph. + +For example, call graph helpers inside an async custom check: + +```ts +import { Rule, pass, scopes, violation } from '@alex-dbs/coderuler'; +import type { Evaluator } from '@alex-dbs/coderuler'; + +export default class CallGraphCheck extends Rule { + files = ['**/*.go']; + scope = scopes.astGrep({ + language: 'go', + rule: { any: [{ kind: 'function_declaration' }, { kind: 'method_declaration' }] }, + }); + + check: Evaluator = async (subject, context) => { + if (subject.kind !== 'scope' || subject.lines === undefined) return pass(); + const target = { filepath: subject.filepath, line: subject.lines.start }; + const [callers, callees] = await Promise.all([ + context.goLsp.callers(target, { depth: 2 }), + context.goLsp.callees(target, { depth: 2, includeDependencies: true }), + ]); + return callers.length + callees.length === 0 + ? pass() + : violation('Review callers and callees'); + }; +} +``` + +The example assumes `pass` and `violation` are imported from the SDK. + In `--diff` and `--pr` checks, the context also exposes: ```ts @@ -162,7 +197,6 @@ class Local extends Rule { } ``` - | Project reader | Result | | --- | --- | | `project.glob(patterns)` | Eligible paths in lexical order |