From 5db7669ed0474012993157c4bbb389b882f21ed2 Mon Sep 17 00:00:00 2001 From: Alexandre Dubois Date: Fri, 6 Nov 2026 00:04:06 +0100 Subject: [PATCH] Ts lsp --- skills/coderuler-new-rule/reference.md | 4 +- src/adapter/filesystem/rule-test-worker.ts | 11 +- src/adapter/filesystem/rule-tests.ts | 28 +- src/adapter/lsp/backend.ts | 21 +- src/app/execute.ts | 9 +- src/app/ports.ts | 1 + src/app/rule.ts | 1 + src/infra/runtime.ts | 7 +- tests/code-scopes.test.ts | 1 + tests/contracts.test.ts | 6 + tests/engine.test.ts | 97 ++++++- tests/fixtures/typescript-lsp/expected.json | 84 ++++++ .../project-dependency/index.ts | 3 + .../.coderuler/typescript-definition.rule.ts | 52 ++++ .../typescript-lsp/project/src/common.cts | 2 + .../typescript-lsp/project/src/module.mts | 2 + .../typescript-lsp/project/src/unicode.ts | 2 + .../typescript-lsp/project/src/usage.ts | 9 + .../typescript-lsp/project/src/view.tsx | 11 + .../typescript-lsp/project/src/widget.ts | 17 ++ .../typescript-lsp/project/tsconfig.json | 11 + tests/lsp-cli-smoke.test.ts | 44 +++ tests/rule-resolution.test.ts | 45 ++- tests/rule-test-pool.test.ts | 67 ++++- tests/structured-scopes.test.ts | 1 + tests/typescript-lsp-support.ts | 16 ++ tests/typescript-lsp.test.ts | 272 ++++++++++++++++++ website/src/content/docs/writing-rules.md | 4 +- 28 files changed, 770 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/typescript-lsp/expected.json create mode 100644 tests/fixtures/typescript-lsp/project-dependency/index.ts create mode 100644 tests/fixtures/typescript-lsp/project/.coderuler/typescript-definition.rule.ts create mode 100644 tests/fixtures/typescript-lsp/project/src/common.cts create mode 100644 tests/fixtures/typescript-lsp/project/src/module.mts create mode 100644 tests/fixtures/typescript-lsp/project/src/unicode.ts create mode 100644 tests/fixtures/typescript-lsp/project/src/usage.ts create mode 100644 tests/fixtures/typescript-lsp/project/src/view.tsx create mode 100644 tests/fixtures/typescript-lsp/project/src/widget.ts create mode 100644 tests/fixtures/typescript-lsp/project/tsconfig.json create mode 100644 tests/typescript-lsp-support.ts create mode 100644 tests/typescript-lsp.test.ts diff --git a/skills/coderuler-new-rule/reference.md b/skills/coderuler-new-rule/reference.md index 76484cb..f826fa6 100644 --- a/skills/coderuler-new-rule/reference.md +++ b/skills/coderuler-new-rule/reference.md @@ -355,9 +355,9 @@ 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. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. LSP use is optional; install Go and `gopls` or Shopify Ruby LSP (`ruby-lsp`) on `PATH` only if a rule uses that language's helpers. CodeRuler does not install either server. An unsupported operation rejects with a capability error; for example, gopls v0.24.0 does not support declaration lookup. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. CodeRuler does not install these servers. An unsupported operation rejects with a capability error; for example, gopls v0.24.0 does not support declaration lookup. -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 (Go only) 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`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. +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 (Go and TypeScript) 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`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. 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. diff --git a/src/adapter/filesystem/rule-test-worker.ts b/src/adapter/filesystem/rule-test-worker.ts index ef2b2b4..085a5c7 100644 --- a/src/adapter/filesystem/rule-test-worker.ts +++ b/src/adapter/filesystem/rule-test-worker.ts @@ -14,6 +14,7 @@ export interface RuleTestWorkerRequest { readonly id: number; readonly lspPort: MessagePort; readonly rubyLspPort: MessagePort; + readonly typescriptLspPort: MessagePort; readonly root: string; readonly ruleFile: string; readonly files: RuleTestCase['files']; @@ -39,7 +40,7 @@ function fixturePath(root: string, filepath: string): string { } async function runCase(request: RuleTestWorkerRequest): Promise { - const { lspPort, rubyLspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request; + const { lspPort, rubyLspPort, typescriptLspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request; try { for (const [filepath, source] of Object.entries(files)) { const target = fixturePath(root, filepath); @@ -60,6 +61,8 @@ async function runCase(request: RuleTestWorkerRequest): Promise createRuleTestLspBackend(lspPort, signal), createRubyLspBackend: (_project, signal) => createRuleTestLspBackend(rubyLspPort, signal), + createTypescriptLspBackend: (_project, signal) => + createRuleTestLspBackend(typescriptLspPort, signal), }); const report = reports[0]; if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report'); @@ -75,7 +78,11 @@ async function runCase(request: RuleTestWorkerRequest): Promise; } @@ -126,7 +127,7 @@ class RuleTestWorkerSession { if (active.closePromise !== undefined) return active.closePromise; active.closePromise = (async () => { try { - const hosts = [active.go.host, active.ruby.host].filter( + const hosts = [active.go.host, active.ruby.host, active.typescript.host].filter( (host): host is { close(): Promise } => host !== undefined, ); const results = await Promise.allSettled(hosts.map(async host => host.close())); @@ -135,9 +136,11 @@ class RuleTestWorkerSession { } finally { active.go.channel.port1.close(); active.ruby.channel.port1.close(); + active.typescript.channel.port1.close(); if (!active.transferred) { active.go.channel.port2.close(); active.ruby.channel.port2.close(); + active.typescript.channel.port2.close(); } if (this.activeLsp === active) this.activeLsp = undefined; } @@ -146,11 +149,12 @@ class RuleTestWorkerSession { } async run( - request: Omit, + request: Omit, deadline: number, onFailure: (error: Error) => void, createBackend?: LspBackendFactory, createRubyLspBackend?: LspBackendFactory, + createTypescriptLspBackend?: LspBackendFactory, ): Promise { this.onFailure = onFailure; this.assertHealthy(); @@ -184,18 +188,27 @@ class RuleTestWorkerSession { channels.push(goChannel); const rubyChannel = new MessageChannel(); channels.push(rubyChannel); + const typescriptChannel = new MessageChannel(); + channels.push(typescriptChannel); activeLsp = { go: { channel: goChannel, host: undefined }, ruby: { channel: rubyChannel, host: undefined }, + typescript: { channel: typescriptChannel, host: undefined }, transferred: false, }; this.activeLsp = activeLsp; const project = createProject(request.root, { excludeFiles: [request.ruleFile] }); activeLsp.go.host = createRuleTestLspHost(goChannel.port1, project, createBackend); activeLsp.ruby.host = createRuleTestLspHost(rubyChannel.port1, project, createRubyLspBackend); + activeLsp.typescript.host = createRuleTestLspHost( + typescriptChannel.port1, project, createTypescriptLspBackend, + ); this.worker.postMessage( - { ...request, id, lspPort: goChannel.port2, rubyLspPort: rubyChannel.port2 } satisfies RuleTestWorkerRequest, - [goChannel.port2, rubyChannel.port2], + { + ...request, id, lspPort: goChannel.port2, rubyLspPort: rubyChannel.port2, + typescriptLspPort: typescriptChannel.port2, + } satisfies RuleTestWorkerRequest, + [goChannel.port2, rubyChannel.port2, typescriptChannel.port2], ); activeLsp.transferred = true; } catch (error) { @@ -273,6 +286,7 @@ async function runCase( includeDisabled: boolean, createLspBackend?: LspBackendFactory, createRubyLspBackend?: LspBackendFactory, + createTypescriptLspBackend?: LspBackendFactory, ): Promise { const started = performance.now(); const deadline = started + timeoutMs; @@ -299,7 +313,7 @@ async function runCase( root, ruleFile: entry.ruleFile, files: example.files, ai, ...(includeDisabled ? { includeDisabled: true } : {}), ...(dependencies === undefined ? {} : { dependencies }), - }, deadline, failCase, createLspBackend, createRubyLspBackend); + }, deadline, failCase, createLspBackend, createRubyLspBackend, createTypescriptLspBackend); if (typeof outcome === 'string') actual = outcome; else { actual = outcome.actual; @@ -340,6 +354,7 @@ export async function runRuleTests( includeDisabled = false, createLspBackend?: LspBackendFactory, createRubyLspBackend?: LspBackendFactory, + createTypescriptLspBackend?: LspBackendFactory, ): Promise { if (!Number.isSafeInteger(concurrency) || concurrency < 1) { throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`); @@ -400,7 +415,8 @@ 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, createLspBackend, createRubyLspBackend, + rule, example, group.ai, timeoutMs, session, fail, includeDisabled, + createLspBackend, createRubyLspBackend, createTypescriptLspBackend, ); if (failureObserved) return; while (completed[nextResult] !== undefined) { diff --git a/src/adapter/lsp/backend.ts b/src/adapter/lsp/backend.ts index d81a7e0..b195e23 100644 --- a/src/adapter/lsp/backend.ts +++ b/src/adapter/lsp/backend.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; -import { basename, isAbsolute, relative, resolve, sep } from 'node:path'; +import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parse, type SgNode, type SgRoot } from '@ast-grep/napi'; import { @@ -263,7 +263,7 @@ export async function createLspBackend( const child = spawn(settings.command, [...(settings.args ?? [])], { cwd: root, env: settings.env, - shell: false, + shell: process.platform === 'win32' && settings.languageId === 'typescript', detached: usesProcessGroup, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -537,7 +537,14 @@ export async function createLspBackend( index: createTextIndex(text), }; await notify('textDocument/didOpen', () => connection!.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { uri, languageId: settings.languageId, version: 1, text }, + textDocument: { + uri, + languageId: settings.languageId === 'typescript' && extname(absolute) === '.tsx' + ? 'typescriptreact' + : settings.languageId, + version: 1, + text, + }, })); return document; })(); @@ -958,3 +965,11 @@ export function createRubyLspBackend(project: Project, signal: AbortSignal): Pro initializationProgressToken: 'indexing-progress', }); } + +export function createTypescriptLspBackend(project: Project, signal: AbortSignal): Promise { + return createLspBackend(project, signal, { + command: 'tsc', + args: ['--lsp', '--stdio'], + languageId: 'typescript', + }); +} diff --git a/src/app/execute.ts b/src/app/execute.ts index b42291d..61dbd91 100644 --- a/src/app/execute.ts +++ b/src/app/execute.ts @@ -86,12 +86,15 @@ export async function executeRules( ): Promise { const goLsp = createRunLsp(context.project, context.createLspBackend); const rubyLsp = createRunLsp(context.project, context.createRubyLspBackend); + const typescriptLsp = createRunLsp(context.project, context.createTypescriptLspBackend); const closeLspSessions = async (): Promise => { - const results = await Promise.allSettled([goLsp.close(), rubyLsp.close()]); + const results = await Promise.allSettled([goLsp.close(), rubyLsp.close(), typescriptLsp.close()]); const goResult = results[0]!; if (goResult.status === 'rejected') throw goResult.reason; const rubyResult = results[1]!; if (rubyResult.status === 'rejected') throw rubyResult.reason; + const typescriptResult = results[2]!; + if (typescriptResult.status === 'rejected') throw typescriptResult.reason; }; const abortLsp = (): void => { void closeLspSessions().catch(() => undefined); }; const startedWork = new Set>(); @@ -108,7 +111,7 @@ export async function executeRules( }; let failed = false; try { - const reports = await executeRun(rules, context, goLsp.tools, rubyLsp.tools, scheduleWork, abortLsp); + const reports = await executeRun(rules, context, goLsp.tools, rubyLsp.tools, typescriptLsp.tools, scheduleWork, abortLsp); await drainStartedWork(); return reports; } catch (error) { @@ -130,6 +133,7 @@ async function executeRun( context: RunContext, goLsp: LspTools, rubyLsp: LspTools, + typescriptLsp: LspTools, scheduleWork: (work: Promise) => Promise, abortLsp: () => void, ): Promise { @@ -160,6 +164,7 @@ async function executeRun( project: context.project, goLsp, rubyLsp, + typescriptLsp, ...(changedFiles === undefined ? {} : { changedFiles }), ...(changedLines === undefined ? {} : { changedLines }), trace: traceFor(name, evaluationId), diff --git a/src/app/ports.ts b/src/app/ports.ts index a4d6294..4b5971b 100644 --- a/src/app/ports.ts +++ b/src/app/ports.ts @@ -99,6 +99,7 @@ export interface RunContext { readonly isTarget: (filepath: string) => boolean; readonly createLspBackend?: LspBackendFactory; readonly createRubyLspBackend?: LspBackendFactory; + readonly createTypescriptLspBackend?: LspBackendFactory; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; readonly concurrency: number; diff --git a/src/app/rule.ts b/src/app/rule.ts index 2e37178..790a2ca 100644 --- a/src/app/rule.ts +++ b/src/app/rule.ts @@ -176,6 +176,7 @@ export interface ProjectContext { readonly project: Project; readonly goLsp: LspTools; readonly rubyLsp: LspTools; + readonly typescriptLsp: LspTools; readonly changedFiles?: ReadonlySet; readonly changedLines?: ReadonlyMap; trace(message: string, details?: JsonValue): void; diff --git a/src/infra/runtime.ts b/src/infra/runtime.ts index 9ee1952..65fad82 100644 --- a/src/infra/runtime.ts +++ b/src/infra/runtime.ts @@ -2,7 +2,7 @@ import { stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; -import { createLspBackend, createRubyLspBackend } from '../adapter/lsp/backend.js'; +import { createLspBackend, createRubyLspBackend, createTypescriptLspBackend } from '../adapter/lsp/backend.js'; import { runProgram } from '../adapter/cli/program.js'; import { terminalCapabilities } from '../adapter/cli/progress.js'; import type { @@ -88,6 +88,7 @@ export function createServices( ...runContext, createLspBackend: runContext.createLspBackend ?? createLspBackend, createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend, + createTypescriptLspBackend: runContext.createTypescriptLspBackend ?? createTypescriptLspBackend, }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), @@ -125,6 +126,7 @@ export function createServices( ...runContext, createLspBackend: runContext.createLspBackend ?? createLspBackend, createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend, + createTypescriptLspBackend: runContext.createTypescriptLspBackend ?? createTypescriptLspBackend, }), decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides), @@ -137,7 +139,8 @@ export function createServices( ...ruleServices, testRules: (rules, onResult, timeoutMs, concurrency, includeAI) => runRuleTests( - rules, onResult, timeoutMs, concurrency, includeAI, false, createLspBackend, createRubyLspBackend, + rules, onResult, timeoutMs, concurrency, includeAI, false, + createLspBackend, createRubyLspBackend, createTypescriptLspBackend, ), }; }, diff --git a/tests/code-scopes.test.ts b/tests/code-scopes.test.ts index 11f361e..d8504b1 100644 --- a/tests/code-scopes.test.ts +++ b/tests/code-scopes.test.ts @@ -37,6 +37,7 @@ const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, rubyLsp: createRunLsp(project).tools, + typescriptLsp: createRunLsp(project).tools, trace: () => {}, }; async function select( diff --git a/tests/contracts.test.ts b/tests/contracts.test.ts index 685dce2..ba4c396 100644 --- a/tests/contracts.test.ts +++ b/tests/contracts.test.ts @@ -97,6 +97,7 @@ class TestContext implements CheckContext { readonly project: Project; readonly goLsp: ProjectContext['goLsp']; readonly rubyLsp: ProjectContext['rubyLsp']; + readonly typescriptLsp: ProjectContext['typescriptLsp']; readonly ruleName: string; readonly traces: CapturedTrace[] = []; readonly selections: unknown[] = []; @@ -109,6 +110,7 @@ class TestContext implements CheckContext { this.project = options.project ?? new MemoryProject(); this.goLsp = createRunLsp(this.project).tools; this.rubyLsp = createRunLsp(this.project).tools; + this.typescriptLsp = createRunLsp(this.project).tools; this.ruleName = options.ruleName ?? 'contract-rule'; this.#subject = options.subject; this.#runtime = options.runtime; @@ -141,6 +143,7 @@ class TestContext implements CheckContext { project: this.project, goLsp: this.goLsp, rubyLsp: this.rubyLsp, + typescriptLsp: this.typescriptLsp, ruleName: this.ruleName, trace: (message, details) => this.trace(message, details), }, @@ -183,6 +186,7 @@ function projectContext(ruleName = 'contract-rule'): ProjectContext & { readonly project, goLsp: createRunLsp(project).tools, rubyLsp: createRunLsp(project).tools, + typescriptLsp: createRunLsp(project).tools, ruleName, trace: () => {}, }; @@ -340,6 +344,7 @@ test('Rule exposes its documented defaults', async () => { project, goLsp: createRunLsp(project).tools, rubyLsp: createRunLsp(project).tools, + typescriptLsp: createRunLsp(project).tools, trace: () => {}, }; assert.equal(await rule.when(context), true); @@ -1069,6 +1074,7 @@ test('an input callback replaces the projected state', async () => { project, goLsp: createRunLsp(project).tools, rubyLsp: createRunLsp(project).tools, + typescriptLsp: createRunLsp(project).tools, ruleName: 'r', trace: () => {}, }, diff --git a/tests/engine.test.ts b/tests/engine.test.ts index ef822cc..f746d4e 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -2906,12 +2906,14 @@ test('nested selection failure is recorded before sibling LSP work is aborted', assert.deepEqual(eventsOfType(harness.events, 'error').map(event => event.message)[0], failure.message); }); -test('Ruby-only work starts only Ruby LSP, and one run can start both language backends', async () => { +test('Ruby-only and TypeScript-only work start only their own LSP, and mixed work starts all three', async () => { const harness = new Harness(new MemoryProject('/repo', { 'a.txt': 'A' })); let goStarts = 0; let rubyStarts = 0; + let typescriptStarts = 0; let goCloses = 0; let rubyCloses = 0; + let typescriptCloses = 0; const context: RunContext = { ...harness.context, createLspBackend: async () => { @@ -2922,6 +2924,10 @@ test('Ruby-only work starts only Ruby LSP, and one run can start both language b rubyStarts += 1; return lifecycleBackend(async () => { rubyCloses += 1; }); }, + createTypescriptLspBackend: async () => { + typescriptStarts += 1; + return lifecycleBackend(async () => { typescriptCloses += 1; }); + }, }; const rubyOnly = new TestRule({ @@ -2935,39 +2941,66 @@ test('Ruby-only work starts only Ruby LSP, and one run can start both language b assert.equal(rubyStarts, 1); assert.equal(goCloses, 0); assert.equal(rubyCloses, 1); + const typescriptOnly = new TestRule({ + when: async projectContext => { + await projectContext.typescriptLsp.hover({ filepath: 'a.ts', line: 1 }); + return true; + }, + }); + await executeRules([loaded('typescript-only', typescriptOnly)], context); + assert.equal(goStarts, 0, 'TypeScript-only work must not start the Go server'); + assert.equal(rubyStarts, 1, 'TypeScript-only work must not start the Ruby server'); + assert.equal(typescriptStarts, 1); + assert.equal(goCloses, 0); + assert.equal(rubyCloses, 1, 'TypeScript-only work must not close a Ruby server it did not start'); + assert.equal(typescriptCloses, 1); const both = new TestRule({ when: async projectContext => { await Promise.all([ projectContext.goLsp.hover({ filepath: 'a.go', line: 1 }), projectContext.rubyLsp.hover({ filepath: 'a.rb', line: 1 }), + projectContext.typescriptLsp.hover({ filepath: 'a.ts', line: 1 }), ]); return true; }, }); - await executeRules([loaded('both-languages', both)], context); + await executeRules([loaded('mixed-languages', both)], context); assert.equal(goStarts, 1); assert.equal(rubyStarts, 2); + assert.equal(typescriptStarts, 2); assert.equal(goCloses, 1); assert.equal(rubyCloses, 2); + assert.equal(typescriptCloses, 2); }); -test('a rule failure aborts pending Ruby work before the run drains it', { timeout: 5000 }, async () => { +test('a rule failure aborts pending Ruby and TypeScript work before the run drains it', { timeout: 5000 }, async () => { const failure = new Error('Ruby-backed check failed'); const requestStarted = Promise.withResolvers(); + const typescriptRequestStarted = Promise.withResolvers(); const requestSettled = Promise.withResolvers(); + const typescriptRequestSettled = Promise.withResolvers(); let aborted = false; + let typescriptAborted = false; let closes = 0; + let typescriptCloses = 0; + const lifecycle: string[] = []; 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.rubyLsp.hover({ filepath: 'a.rb', line: 2 }); + await Promise.all([ + context.rubyLsp.hover({ filepath: 'a.rb', line: 2 }), + context.typescriptLsp.hover({ filepath: 'a.ts', line: 2 }), + ]); throw failure; } - await context.rubyLsp.hover({ filepath: 'a.rb', line: 1 }); + await Promise.all([ + context.rubyLsp.hover({ filepath: 'a.rb', line: 1 }), + context.typescriptLsp.hover({ filepath: 'a.ts', line: 1 }), + ]); return pass(); }, }); @@ -2975,33 +3008,69 @@ test('a rule failure aborts pending Ruby work before the run drains it', { timeo const context: RunContext = { ...harness.context, createRubyLspBackend: async (_project, signal) => ({ - ...lifecycleBackend(async () => { closes += 1; }), + ...lifecycleBackend(async () => { + closes += 1; + lifecycle.push('ruby-close'); + }), hover: async target => { if (target.line === 1) { const pending = Promise.withResolvers(); signal.addEventListener('abort', () => { aborted = true; + lifecycle.push('ruby-abort'); pending.reject(new Error('Ruby LSP request aborted')); }, { once: true }); requestStarted.resolve(); - return pending.promise.finally(() => requestSettled.resolve()); + return pending.promise.finally(() => { + lifecycle.push('ruby-settled'); + requestSettled.resolve(); + }); } await requestStarted.promise; return null; }, }), + createTypescriptLspBackend: async (_project, signal) => ({ + ...lifecycleBackend(async () => { + typescriptCloses += 1; + lifecycle.push('typescript-close'); + }), + hover: async target => { + if (target.line === 1) { + const pending = Promise.withResolvers(); + signal.addEventListener('abort', () => { + typescriptAborted = true; + lifecycle.push('typescript-abort'); + pending.reject(new Error('TypeScript LSP request aborted')); + }, { once: true }); + typescriptRequestStarted.resolve(); + return pending.promise.finally(() => { + lifecycle.push('typescript-settled'); + typescriptRequestSettled.resolve(); + }); + } + await typescriptRequestStarted.promise; + return null; + }, + }), }; await assert.rejects( - () => executeRules([loaded('ruby-failure-before-drain', rule)], context), + () => executeRules([loaded('ruby-typescript-failure-before-drain', rule)], context), error => error === failure, ); - await requestSettled.promise; + await Promise.all([requestSettled.promise, typescriptRequestSettled.promise]); assert.equal(aborted, true, 'pending Ruby work must observe run cancellation'); + assert.equal(typescriptAborted, true, 'pending TypeScript work must observe run cancellation'); assert.equal(closes, 1); + assert.equal(typescriptCloses, 1); + const firstClose = Math.min(lifecycle.indexOf('ruby-close'), lifecycle.indexOf('typescript-close')); + for (const event of ['ruby-abort', 'typescript-abort', 'ruby-settled', 'typescript-settled']) { + assert.ok(lifecycle.indexOf(event) < firstClose, `${event} must happen before either backend closes`); + } }); -test('run cleanup closes both language backends and preserves primary or standalone failures', async () => { +test('run cleanup closes all three language backends and preserves primary or standalone failures', async () => { const project = new MemoryProject('/repo', { 'a.txt': 'A' }); const ruleFailure = new Error('check failed'); const closeFailure = new Error('Go backend close failed'); @@ -3013,6 +3082,7 @@ test('run cleanup closes both language backends and preserves primary or standal await Promise.all([ context.goLsp.hover({ filepath: 'a.go', line: 1 }), context.rubyLsp.hover({ filepath: 'a.rb', line: 1 }), + context.typescriptLsp.hover({ filepath: 'a.ts', line: 1 }), ]); return true; }, @@ -3031,13 +3101,16 @@ test('run cleanup closes both language backends and preserves primary or standal createRubyLspBackend: async () => lifecycleBackend(async () => { closed.push('ruby'); }), + createTypescriptLspBackend: async () => lifecycleBackend(async () => { + closed.push('typescript'); + }), }; await assert.rejects( - () => executeRules([loaded('close-both-languages', rule)], context), + () => executeRules([loaded('close-all-languages', rule)], context), error => error === (failsInRule ? ruleFailure : closeFailure), ); - assert.deepEqual([...closed].sort(), ['go', 'ruby'], 'Ruby must still close when Go cleanup fails'); + assert.deepEqual(closed, ['go', 'ruby', 'typescript'], 'Ruby and TypeScript must still close when the first cleanup fails'); } }); diff --git a/tests/fixtures/typescript-lsp/expected.json b/tests/fixtures/typescript-lsp/expected.json new file mode 100644 index 0000000..1ca45e4 --- /dev/null +++ b/tests/fixtures/typescript-lsp/expected.json @@ -0,0 +1,84 @@ +{ + "widgetDefinitions": [ + { "path": "src/widget.ts", "line": 5 } + ], + "widgetTypeDefinitions": [ + { "path": "src/widget.ts", "line": 5 } + ], + "builderImplementations": [ + { "path": "src/widget.ts", "line": 5 } + ], + "widgetReferencesWithDeclaration": [ + { "path": "src/unicode.ts", "line": 1 }, + { "path": "src/unicode.ts", "line": 2 }, + { "path": "src/usage.ts", "line": 1 }, + { "path": "src/usage.ts", "line": 4 }, + { "path": "src/widget.ts", "line": 5 } + ], + "widgetReferencesWithoutDeclaration": [ + { "path": "src/unicode.ts", "line": 2 }, + { "path": "src/usage.ts", "line": 4 } + ], + "widgetDocumentSymbols": [ + { "name": "Builder", "kind": 11, "path": "src/widget.ts", "line": 1 }, + { "name": "build", "kind": 6, "path": "src/widget.ts", "line": 2 }, + { "name": "Widget", "kind": 5, "path": "src/widget.ts", "line": 5 }, + { "name": "build", "kind": 6, "path": "src/widget.ts", "line": 6 }, + { "name": "leaf", "kind": 12, "path": "src/widget.ts", "line": 11 }, + { "name": "label", "kind": 12, "path": "src/widget.ts", "line": 15 } + ], + "widgetWorkspaceSymbols": [ + { "name": "unicodeWidget", "kind": 13, "path": "src/unicode.ts", "line": 2 }, + { "name": "Widget", "kind": 5, "path": "src/widget.ts", "line": 5 } + ], + "tsxLabelDefinitions": [ + { "path": "src/widget.ts", "line": 15 } + ], + "unicodeWidgetDefinitions": [ + { "path": "src/widget.ts", "line": 5 } + ], + "moduleValueDefinitions": [ + { "path": "src/module.mts", "line": 1 } + ], + "commonValueDefinitions": [ + { "path": "src/common.cts", "line": 1 } + ], + "hoverIdentifiesWidget": true, + "runCalleesDepth2": [ + { + "name": "build", + "path": "src/widget.ts", + "depth": 1, + "source": "build(): string {\n return leaf();\n }", + "lines": { "start": 6, "end": 8 } + }, + { + "name": "leaf", + "path": "src/widget.ts", + "depth": 2, + "source": "export function leaf(): string {\n return 'widget';\n}", + "lines": { "start": 11, "end": 13 } + } + ], + "leafCallersDepth2": [ + { + "name": "build", + "path": "src/widget.ts", + "depth": 1, + "source": "build(): string {\n return leaf();\n }", + "lines": { "start": 6, "end": 8 } + }, + { + "name": "run", + "path": "src/usage.ts", + "depth": 2, + "source": "export function run(): string {\n return widget.build();\n}", + "lines": { "start": 5, "end": 7 } + } + ], + "externalDefinitions": [], + "externalDependencyDefinitions": [ + { "path": "/index.ts", "line": 1 } + ], + "nonSymbolDefinitions": [] +} diff --git a/tests/fixtures/typescript-lsp/project-dependency/index.ts b/tests/fixtures/typescript-lsp/project-dependency/index.ts new file mode 100644 index 0000000..9f4deca --- /dev/null +++ b/tests/fixtures/typescript-lsp/project-dependency/index.ts @@ -0,0 +1,3 @@ +export function external(): string { + return 'external'; +} diff --git a/tests/fixtures/typescript-lsp/project/.coderuler/typescript-definition.rule.ts b/tests/fixtures/typescript-lsp/project/.coderuler/typescript-definition.rule.ts new file mode 100644 index 0000000..06009fc --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/.coderuler/typescript-definition.rule.ts @@ -0,0 +1,52 @@ +import { Rule, pass, test, violation, type ProjectContext, type Subject } from '@alex-dbs/coderuler'; + +const tsconfig = JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + jsx: 'preserve', + strict: true, + noEmit: true, + types: [], + }, + include: ['src/**/*'], +}, null, 2); +const widget = `export interface Builder {} +export class Widget implements Builder {} +`; +const usage = `import { Widget, type Builder } from './widget.js'; + + +export const widget = new Widget(); +export const builder: Builder = widget; +`; + +export default class TypescriptDefinition extends Rule { + files = ['src/usage.ts']; + + check = async (subject: Subject, context: ProjectContext) => { + if (subject.kind === 'group') return pass(); + + const definitions = await context.typescriptLsp.definitions({ + filepath: subject.filepath, + line: 4, + column: 27, + }); + return definitions.some(definition => definition.uri.endsWith('/src/widget.ts')) + ? pass() + : violation('Widget must resolve to src/widget.ts'); + }; +} + +export const tests = [ + test('Widget resolves to its definition').withFiles({ + 'tsconfig.json': tsconfig, + 'src/widget.ts': widget, + 'src/usage.ts': usage, + }).passes(), + test('an unknown widget does not resolve').withFiles({ + 'tsconfig.json': tsconfig, + 'src/widget.ts': widget, + 'src/usage.ts': usage.replace('new Widget()', 'new UnknownWidget()'), + }).violates(), +] as const; diff --git a/tests/fixtures/typescript-lsp/project/src/common.cts b/tests/fixtures/typescript-lsp/project/src/common.cts new file mode 100644 index 0000000..257057f --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/common.cts @@ -0,0 +1,2 @@ +export const commonValue = 'common'; +export const commonReference = commonValue; diff --git a/tests/fixtures/typescript-lsp/project/src/module.mts b/tests/fixtures/typescript-lsp/project/src/module.mts new file mode 100644 index 0000000..a285585 --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/module.mts @@ -0,0 +1,2 @@ +export const moduleValue = 'module'; +export const moduleReference = moduleValue; diff --git a/tests/fixtures/typescript-lsp/project/src/unicode.ts b/tests/fixtures/typescript-lsp/project/src/unicode.ts new file mode 100644 index 0000000..e26279b --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/unicode.ts @@ -0,0 +1,2 @@ +import { Widget } from './widget.js'; +export const marker = "𝄞"; export const unicodeWidget = new Widget(); diff --git a/tests/fixtures/typescript-lsp/project/src/usage.ts b/tests/fixtures/typescript-lsp/project/src/usage.ts new file mode 100644 index 0000000..0e9aeca --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/usage.ts @@ -0,0 +1,9 @@ +import { Widget, type Builder } from './widget.js'; +import { external } from '../../project-dependency/index.js'; + +export const widget = new Widget(); +export function run(): string { + return widget.build(); +} +export const builder: Builder = widget; +export const externalValue = external(); diff --git a/tests/fixtures/typescript-lsp/project/src/view.tsx b/tests/fixtures/typescript-lsp/project/src/view.tsx new file mode 100644 index 0000000..7c41442 --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/view.tsx @@ -0,0 +1,11 @@ +import { label } from './widget.js'; + +declare global { + namespace JSX { + interface IntrinsicElements { + section: { children?: unknown }; + } + } +} + +export const view =
{label()}
; diff --git a/tests/fixtures/typescript-lsp/project/src/widget.ts b/tests/fixtures/typescript-lsp/project/src/widget.ts new file mode 100644 index 0000000..f7f1b30 --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/src/widget.ts @@ -0,0 +1,17 @@ +export interface Builder { + build(): string; +} + +export class Widget implements Builder { + build(): string { + return leaf(); + } +} + +export function leaf(): string { + return 'widget'; +} + +export function label(): string { + return 'view'; +} diff --git a/tests/fixtures/typescript-lsp/project/tsconfig.json b/tests/fixtures/typescript-lsp/project/tsconfig.json new file mode 100644 index 0000000..d95d751 --- /dev/null +++ b/tests/fixtures/typescript-lsp/project/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "jsx": "preserve", + "strict": true, + "noEmit": true, + "types": [] + }, + "include": ["src/**/*"] +} diff --git a/tests/lsp-cli-smoke.test.ts b/tests/lsp-cli-smoke.test.ts index 6ea8faa..1d4e838 100644 --- a/tests/lsp-cli-smoke.test.ts +++ b/tests/lsp-cli-smoke.test.ts @@ -4,6 +4,7 @@ import { after, test } from 'node:test'; import { join } from 'node:path'; import { createRubyLspFixture } from './ruby-lsp-support.ts'; +import { createTypescriptLspFixture } from './typescript-lsp-support.ts'; import { createSandbox, ROOT, runCli, writeFile } from './support.ts'; const sandbox = createSandbox('coderuler-lsp-cli-'); @@ -64,6 +65,49 @@ test('test runs both Ruby fixture examples with their declared outcomes', async assert.equal(result.stdout.match(/^.*PASS.*$/gm)?.length, 2, result.stdout); }); +test('TypeScript LSP check uses the default backend to resolve the TypeScript fixture', async () => { + const target = await createTypescriptLspFixture(sandbox.isolated('typescript-check')); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/typescript-definition.rule.ts', '--json', '--output-all', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /"status":\s*"pass"/); +}); + +test('TypeScript LSP inspect uses the default backend to resolve the TypeScript fixture', async () => { + const target = await createTypescriptLspFixture(sandbox.isolated('typescript-inspect')); + const result = await runCli([ + 'inspect', '.coderuler/typescript-definition.rule.ts', '.', '--json', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stderr, ''); + assert.match(result.stdout, /"status":\s*"pass"/); +}); + +test('TypeScript LSP check reports the exact violation for an unresolved widget', async () => { + const target = await createTypescriptLspFixture(sandbox.isolated('typescript-check-violation')); + const usagePath = join(target.cwd, 'src', 'usage.ts'); + const usage = readFileSync(usagePath, 'utf8'); + assert.ok(usage.includes('new Widget()'), usage); + writeFile(usagePath, usage.replace('new Widget()', 'new UnknownWidget()')); + const result = await runCli([ + 'check', '.', '--rules', '.coderuler/typescript-definition.rule.ts', '--json', '--output-all', + ], target); + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.ok(result.stdout.includes('Widget must resolve to src/widget.ts'), result.stdout); +}); + +test('TypeScript LSP test reports both inline outcomes as passing cases', async () => { + const target = await createTypescriptLspFixture(sandbox.isolated('typescript-test')); + const result = await runCli([ + 'test', '.coderuler/typescript-definition.rule.ts', '--timeout', '60s', + ], target); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(result.stdout.match(/^.*PASS.*$/gm)?.length, 2, result.stdout); + assert.ok(result.stdout.includes('Widget resolves to its definition'), result.stdout); + assert.ok(result.stdout.includes('an unknown widget does not resolve'), result.stdout); +}); + const offlineGo = { GOPROXY: 'off', GOWORK: 'off', diff --git a/tests/rule-resolution.test.ts b/tests/rule-resolution.test.ts index 489bf56..12691f1 100644 --- a/tests/rule-resolution.test.ts +++ b/tests/rule-resolution.test.ts @@ -711,7 +711,7 @@ function startWorker(prelude = ''): Worker { async function requestWorker( worker: Worker, - request: Omit, + request: Omit, ): Promise { const result = Promise.withResolvers(); const onMessage = (message: RuleTestWorkerMessage) => { @@ -719,19 +719,30 @@ async function requestWorker( }; 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 rubyChannel = new MessageChannel(); - const project = createProject(request.root, { excludeFiles: [request.ruleFile] }); - const host = createRuleTestLspHost(channel.port1, project); - const rubyHost = createRuleTestLspHost(rubyChannel.port1, project); + let channel: MessageChannel | undefined; + let rubyChannel: MessageChannel | undefined; + let typescriptChannel: MessageChannel | undefined; + let host: { close(): Promise } | undefined; + let rubyHost: { close(): Promise } | undefined; + let typescriptHost: { close(): Promise } | undefined; + let transferred = false; worker.on('message', onMessage); worker.on('error', onError); worker.on('exit', onExit); - let transferred = false; try { + channel = new MessageChannel(); + rubyChannel = new MessageChannel(); + typescriptChannel = new MessageChannel(); + const project = createProject(request.root, { excludeFiles: [request.ruleFile] }); + host = createRuleTestLspHost(channel.port1, project); + rubyHost = createRuleTestLspHost(rubyChannel.port1, project); + typescriptHost = createRuleTestLspHost(typescriptChannel.port1, project); worker.postMessage( - { ...request, lspPort: channel.port2, rubyLspPort: rubyChannel.port2 } satisfies RuleTestWorkerRequest, - [channel.port2, rubyChannel.port2], + { + ...request, lspPort: channel.port2, rubyLspPort: rubyChannel.port2, + typescriptLspPort: typescriptChannel.port2, + } satisfies RuleTestWorkerRequest, + [channel.port2, rubyChannel.port2, typescriptChannel.port2], ); transferred = true; return await result.promise; @@ -740,15 +751,21 @@ async function requestWorker( worker.off('error', onError); worker.off('exit', onExit); try { - const closes = await Promise.allSettled([host.close(), rubyHost.close()]); + const closes = await Promise.allSettled( + [host, rubyHost, typescriptHost] + .filter((item): item is { close(): Promise } => item !== undefined) + .map(item => item.close()), + ); const failed = closes.find((result): result is PromiseRejectedResult => result.status === 'rejected'); if (failed !== undefined) throw failed.reason; } finally { - channel.port1.close(); - rubyChannel.port1.close(); + channel?.port1.close(); + rubyChannel?.port1.close(); + typescriptChannel?.port1.close(); if (!transferred) { - channel.port2.close(); - rubyChannel.port2.close(); + channel?.port2.close(); + rubyChannel?.port2.close(); + typescriptChannel?.port2.close(); } } } diff --git a/tests/rule-test-pool.test.ts b/tests/rule-test-pool.test.ts index 2a22ff6..72816ce 100644 --- a/tests/rule-test-pool.test.ts +++ b/tests/rule-test-pool.test.ts @@ -438,7 +438,7 @@ 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 () => { +test('inline rule tests close fresh parent-owned Go, Ruby, and TypeScript backends 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( @@ -448,14 +448,15 @@ test('inline rule tests close one fresh parent-owned LSP backend per successful `async (_subject, context) => { await context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); await context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); + await context.typescriptLsp.hover({ filepath: 'inputs/one.txt', line: 1 }); return pass(); }`, cases(['first', 'second']), ), ); - const openedRoots = { go: [] as string[], ruby: [] as string[] }; - const closedRoots = { go: [] as string[], ruby: [] as string[] }; - const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; + const openedRoots = { go: [] as string[], ruby: [] as string[], typescript: [] as string[] }; + const closedRoots = { go: [] as string[], ruby: [] as string[], typescript: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[], typescript: [] as boolean[] }; const createBackend: LspBackendFactory = async project => { openedRoots.go.push(project.root); return lspBackend(() => { @@ -470,23 +471,36 @@ test('inline rule tests close one fresh parent-owned LSP backend per successful rootsAtClose.ruby.push(existsSync(project.root)); }, async () => null); }; + const createTypescriptBackend: LspBackendFactory = async project => { + openedRoots.typescript.push(project.root); + return lspBackend(() => { + closedRoots.typescript.push(project.root); + rootsAtClose.typescript.push(existsSync(project.root)); + }, async () => null); + }; - const results = await runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend, createRubyBackend); + const results = await runRuleTests(rules, undefined, 10_000, 1, false, false, createBackend, createRubyBackend, createTypescriptBackend); assert.deepEqual(results.map(result => result.actual), ['pass', 'pass']); assert.equal(openedRoots.go.length, 2); assert.equal(openedRoots.ruby.length, 2); + assert.equal(openedRoots.typescript.length, 2); assert.equal(new Set(openedRoots.go).size, 2, 'each reused-worker case must get its own Go backend'); assert.equal(new Set(openedRoots.ruby).size, 2, 'each reused-worker case must get its own Ruby backend'); + assert.equal(new Set(openedRoots.typescript).size, 2, 'each reused-worker case must get its own TypeScript backend'); assert.deepEqual(openedRoots.ruby, openedRoots.go, 'both hosts must use the same case project'); + assert.deepEqual(openedRoots.typescript, openedRoots.go, 'all hosts must use the same case project'); assert.deepEqual(closedRoots.go, openedRoots.go); assert.deepEqual(closedRoots.ruby, openedRoots.ruby); + assert.deepEqual(closedRoots.typescript, openedRoots.typescript); assert.deepEqual(rootsAtClose.go, [true, true], 'the Go host must close before its case root is removed'); assert.deepEqual(rootsAtClose.ruby, [true, true], 'the Ruby host must close before its case root is removed'); + assert.deepEqual(rootsAtClose.typescript, [true, true], 'the TypeScript host must close before its case root is removed'); for (const root of openedRoots.go) assert.equal(existsSync(root), false, root); for (const root of openedRoots.ruby) assert.equal(existsSync(root), false, root); + for (const root of openedRoots.typescript) assert.equal(existsSync(root), false, root); }); -test('inline rule timeout closes pending Go and Ruby hosts before removing its root', { timeout: 20_000 }, async () => { +test('inline rule timeout closes pending Go, Ruby, and TypeScript hosts 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( @@ -497,18 +511,27 @@ test('inline rule timeout closes pending Go and Ruby hosts before removing its r await Promise.all([ context.goLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), context.rubyLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), + context.typescriptLsp.hover({ filepath: 'inputs/one.txt', line: 1 }), ]); return pass(); }`, cases(['pending']), ), ); - const started = Promise.withResolvers<{ go: string; ruby: string }>(); - const roots = { go: undefined as string | undefined, ruby: undefined as string | undefined }; - const closedRoots = { go: [] as string[], ruby: [] as string[] }; - const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[] }; + const started = Promise.withResolvers<{ go: string; ruby: string; typescript: string }>(); + const typescriptRequestStarted = Promise.withResolvers(); + const roots = { + go: undefined as string | undefined, + ruby: undefined as string | undefined, + typescript: undefined as string | undefined, + }; + const closedRoots = { go: [] as string[], ruby: [] as string[], typescript: [] as string[] }; + const rootsAtClose = { go: [] as boolean[], ruby: [] as boolean[], typescript: [] as boolean[] }; + const typescriptLifecycle: string[] = []; const onStarted = (): void => { - if (roots.go !== undefined && roots.ruby !== undefined) started.resolve({ go: roots.go, ruby: roots.ruby }); + if (roots.go !== undefined && roots.ruby !== undefined && roots.typescript !== undefined) { + started.resolve({ go: roots.go, ruby: roots.ruby, typescript: roots.typescript }); + } }; const createBackend: LspBackendFactory = async (project, signal) => { roots.go = project.root; @@ -526,21 +549,39 @@ test('inline rule timeout closes pending Go and Ruby hosts before removing its r rootsAtClose.ruby.push(existsSync(project.root)); }, () => hoverUntilAbort(signal)); }; + const createTypescriptBackend: LspBackendFactory = async (project, signal) => { + roots.typescript = project.root; + signal.addEventListener('abort', () => typescriptLifecycle.push('abort'), { once: true }); + onStarted(); + return lspBackend(() => { + closedRoots.typescript.push(project.root); + rootsAtClose.typescript.push(existsSync(project.root)); + typescriptLifecycle.push('close'); + }, () => { + typescriptRequestStarted.resolve(); + return 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, createRubyBackend); + const execution = runRuleTests(rules, undefined, 4_000, 1, false, false, createBackend, createRubyBackend, createTypescriptBackend); const startedOrFinished = await Promise.race([ started.promise.then(projectRoots => ({ projectRoots })), execution.then(() => ({ finished: true }), error => ({ error })), ]); - assert.ok('projectRoots' in startedOrFinished, 'the case must reach both parent-owned LSP hosts before timeout'); + assert.ok('projectRoots' in startedOrFinished, 'the case must reach all three parent-owned LSP hosts before timeout'); + await typescriptRequestStarted.promise; const [result] = await execution; assert.equal(result?.actual, 'timeout'); assert.deepEqual(closedRoots.go, [startedOrFinished.projectRoots.go]); assert.deepEqual(closedRoots.ruby, [startedOrFinished.projectRoots.ruby]); + assert.deepEqual(closedRoots.typescript, [startedOrFinished.projectRoots.typescript]); assert.deepEqual(rootsAtClose.go, [true]); assert.deepEqual(rootsAtClose.ruby, [true]); + assert.deepEqual(rootsAtClose.typescript, [true]); + assert.deepEqual(typescriptLifecycle, ['abort', 'close'], 'the pending TypeScript request must abort before its host closes'); assert.equal(existsSync(startedOrFinished.projectRoots.go), false); assert.equal(existsSync(startedOrFinished.projectRoots.ruby), false); + assert.equal(existsSync(startedOrFinished.projectRoots.typescript), false); }); test('inline sibling failure closes pending Go and Ruby hosts before removing its root', { timeout: 20_000 }, async context => { diff --git a/tests/structured-scopes.test.ts b/tests/structured-scopes.test.ts index fec1855..97429ac 100644 --- a/tests/structured-scopes.test.ts +++ b/tests/structured-scopes.test.ts @@ -62,6 +62,7 @@ const projectContext: ProjectContext = { project, goLsp: createRunLsp(project).tools, rubyLsp: createRunLsp(project).tools, + typescriptLsp: createRunLsp(project).tools, trace: () => {}, }; function collect( diff --git a/tests/typescript-lsp-support.ts b/tests/typescript-lsp-support.ts new file mode 100644 index 0000000..19cf916 --- /dev/null +++ b/tests/typescript-lsp-support.ts @@ -0,0 +1,16 @@ +import { cp, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { Sandbox } from './support.js'; + +const FIXTURE_ROOT = fileURLToPath(new URL('./fixtures/typescript-lsp/', import.meta.url)); + +export async function createTypescriptLspFixture(target: Sandbox): Promise { + const projectFixture = join(FIXTURE_ROOT, 'project'); + for (const entry of await readdir(projectFixture)) { + await cp(join(projectFixture, entry), join(target.cwd, entry), { recursive: true }); + } + await cp(join(FIXTURE_ROOT, 'project-dependency'), join(target.root, 'project-dependency'), { recursive: true }); + return target; +} diff --git a/tests/typescript-lsp.test.ts b/tests/typescript-lsp.test.ts new file mode 100644 index 0000000..2f461e8 --- /dev/null +++ b/tests/typescript-lsp.test.ts @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; + +import { createProject } from '../dist/adapter/filesystem/project.js'; +import { createTypescriptLspBackend } from '../dist/adapter/lsp/backend.js'; +import { createRunLsp } from '../dist/app/lsp.js'; +import type { + DocumentSymbol, + Location, + LspCall, + SymbolInformation, + WorkspaceSymbol, +} from '../dist/index.js'; +import { createSandbox } from './support.ts'; +import { createTypescriptLspFixture } from './typescript-lsp-support.ts'; + +interface LocationView { + readonly path: string; + readonly line: number; +} + +interface SymbolView { + readonly name: string; + readonly kind: number; + readonly path: string; + readonly line: number | null; +} + +interface CallView { + readonly name: string; + readonly path: string; + readonly depth: number; + readonly source: string; + readonly lines: { readonly start: number; readonly end: number }; +} + +interface TypescriptLspGoldens { + readonly widgetDefinitions: readonly LocationView[]; + readonly widgetTypeDefinitions: readonly LocationView[]; + readonly builderImplementations: readonly LocationView[]; + readonly widgetReferencesWithDeclaration: readonly LocationView[]; + readonly widgetReferencesWithoutDeclaration: readonly LocationView[]; + readonly widgetDocumentSymbols: readonly SymbolView[]; + readonly widgetWorkspaceSymbols: readonly SymbolView[]; + readonly tsxLabelDefinitions: readonly LocationView[]; + readonly unicodeWidgetDefinitions: readonly LocationView[]; + readonly moduleValueDefinitions: readonly LocationView[]; + readonly commonValueDefinitions: readonly LocationView[]; + readonly hoverIdentifiesWidget: boolean; + readonly runCalleesDepth2: readonly CallView[]; + readonly leafCallersDepth2: readonly CallView[]; + readonly externalDefinitions: readonly LocationView[]; + readonly externalDependencyDefinitions: readonly LocationView[]; + readonly nonSymbolDefinitions: readonly LocationView[]; +} + +function normalizePath(path: string, projectRoot: string, dependencyRoot: string): string { + if (!isAbsolute(path)) return path.split(sep).join('/'); + const projectPath = relative(projectRoot, path); + if (projectPath !== '..' && !projectPath.startsWith(`..${sep}`) && !isAbsolute(projectPath)) { + return projectPath.split(sep).join('/'); + } + const dependencyPath = relative(dependencyRoot, path); + if (dependencyPath !== '..' && !dependencyPath.startsWith(`..${sep}`) && !isAbsolute(dependencyPath)) { + return `/${dependencyPath.split(sep).join('/')}`; + } + throw new Error(`TypeScript LSP returned a path outside the fixture: ${path}`); +} + +function locationView( + locations: readonly Location[], + projectRoot: string, + dependencyRoot: string, +): readonly LocationView[] { + return locations + .map(location => ({ + path: normalizePath(fileURLToPath(location.uri), projectRoot, dependencyRoot), + line: location.range.start.line + 1, + })) + .sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line); +} + +function documentSymbolView( + symbols: readonly DocumentSymbol[] | readonly SymbolInformation[], + filepath: string, +): readonly SymbolView[] { + const result: SymbolView[] = []; + const visit = (items: readonly (DocumentSymbol | SymbolInformation)[]): void => { + for (const symbol of items) { + if ('location' in symbol) { + result.push({ + name: symbol.name, + kind: symbol.kind, + path: filepath, + line: symbol.location.range.start.line + 1, + }); + } else { + result.push({ + name: symbol.name, + kind: symbol.kind, + path: filepath, + line: symbol.selectionRange.start.line + 1, + }); + if (symbol.children !== undefined) visit(symbol.children); + } + } + }; + visit(symbols); + return result.sort((left, right) => left.path.localeCompare(right.path) + || (left.line ?? 0) - (right.line ?? 0) + || left.name.localeCompare(right.name)); +} + +function workspaceSymbolView( + symbols: readonly (SymbolInformation | WorkspaceSymbol)[], + projectRoot: string, + dependencyRoot: string, +): readonly SymbolView[] { + return symbols + .map(symbol => ({ + name: symbol.name, + kind: symbol.kind, + path: normalizePath(fileURLToPath(symbol.location.uri), projectRoot, dependencyRoot), + line: 'range' in symbol.location ? symbol.location.range.start.line + 1 : null, + })) + .sort((left, right) => left.path.localeCompare(right.path) + || left.name.localeCompare(right.name) + || (left.line ?? 0) - (right.line ?? 0)); +} + +function callView( + calls: readonly LspCall[], + projectRoot: string, + dependencyRoot: string, +): readonly CallView[] { + return calls + .map(call => ({ + name: call.name, + path: normalizePath(call.filepath, projectRoot, dependencyRoot), + depth: call.depth, + source: call.source, + lines: call.lines, + })) + .sort((left, right) => left.depth - right.depth + || left.path.localeCompare(right.path) + || left.lines.start - right.lines.start + || left.name.localeCompare(right.name)); +} + +function targetAtToken(filepath: string, source: string, token: string, lineMarker = token) { + const lines = source.split(/\r?\n/u); + const lineIndex = lines.findIndex(line => line.includes(lineMarker)); + const line = lines[lineIndex]; + if (lineIndex < 0 || line === undefined) throw new Error(`Could not find '${lineMarker}' in ${filepath}`); + const columnIndex = line.lastIndexOf(token); + if (columnIndex < 0) throw new Error(`Could not find token '${token}' in ${filepath}:${lineIndex + 1}`); + return { filepath, line: lineIndex + 1, column: columnIndex + 1 }; +} + +function lineNumber(filepath: string, source: string, lineMarker: string): number { + const line = source.split(/\r?\n/u).findIndex(candidate => candidate.includes(lineMarker)); + if (line < 0) throw new Error(`Could not find '${lineMarker}' in ${filepath}`); + return line + 1; +} + +test('real TypeScript LSP navigation matches the fixture golden', { timeout: 180_000 }, async () => { + const sandboxes = createSandbox('typescript-lsp-test-'); + const fixture = await createTypescriptLspFixture(sandboxes.isolated('typescript')); + const project = createProject(fixture.cwd); + const dependencyRoot = join(fixture.root, 'project-dependency'); + const session = createRunLsp(project, createTypescriptLspBackend); + + try { + const goldenPath = fileURLToPath(new URL('./fixtures/typescript-lsp/expected.json', import.meta.url)); + const golden = JSON.parse(await readFile(goldenPath, 'utf8')) as TypescriptLspGoldens; + const { tools } = session; + const widgetSource = await readFile(join(fixture.cwd, 'src', 'widget.ts'), 'utf8'); + const usageSource = await readFile(join(fixture.cwd, 'src', 'usage.ts'), 'utf8'); + const viewSource = await readFile(join(fixture.cwd, 'src', 'view.tsx'), 'utf8'); + const unicodeSource = await readFile(join(fixture.cwd, 'src', 'unicode.ts'), 'utf8'); + const moduleSource = await readFile(join(fixture.cwd, 'src', 'module.mts'), 'utf8'); + const commonSource = await readFile(join(fixture.cwd, 'src', 'common.cts'), 'utf8'); + + const widgetTarget = targetAtToken('src/usage.ts', usageSource, 'Widget', 'new Widget()'); + const widgetInstanceTarget = targetAtToken('src/usage.ts', usageSource, 'widget', 'widget.build()'); + const builderTarget = targetAtToken('src/usage.ts', usageSource, 'Builder', 'builder: Builder'); + const tsxLabelTarget = targetAtToken('src/view.tsx', viewSource, 'label', '{label()}'); + const unicodeWidgetTarget = targetAtToken('src/unicode.ts', unicodeSource, 'Widget', 'new Widget()'); + const moduleReferenceTarget = targetAtToken( + 'src/module.mts', moduleSource, 'moduleValue', 'moduleReference = moduleValue', + ); + const commonReferenceTarget = targetAtToken( + 'src/common.cts', commonSource, 'commonValue', 'commonReference = commonValue', + ); + const externalTarget = targetAtToken('src/usage.ts', usageSource, 'external', 'externalValue = external()'); + const runLineTarget = { + filepath: 'src/usage.ts', + line: lineNumber('src/usage.ts', usageSource, 'return widget.build();'), + }; + const leafTarget = targetAtToken('src/widget.ts', widgetSource, 'leaf', 'export function leaf'); + const blankLine = usageSource.split(/\r?\n/u).findIndex(line => line.length === 0); + if (blankLine < 0) throw new Error('Could not find a non-symbol line in src/usage.ts'); + const nonSymbolTarget = { filepath: 'src/usage.ts', line: blankLine + 1, column: 1 }; + + const widgetHover = await tools.hover(widgetTarget); + const actual: TypescriptLspGoldens = { + widgetDefinitions: locationView( + await tools.definitions(widgetTarget), fixture.cwd, dependencyRoot, + ), + widgetTypeDefinitions: locationView( + await tools.typeDefinitions(widgetInstanceTarget), fixture.cwd, dependencyRoot, + ), + builderImplementations: locationView( + await tools.implementations(builderTarget), fixture.cwd, dependencyRoot, + ), + widgetReferencesWithDeclaration: locationView( + await tools.references(widgetTarget, { includeDeclaration: true }), fixture.cwd, dependencyRoot, + ), + widgetReferencesWithoutDeclaration: locationView( + await tools.references(widgetTarget, { includeDeclaration: false }), fixture.cwd, dependencyRoot, + ), + widgetDocumentSymbols: documentSymbolView( + await tools.documentSymbols('src/widget.ts'), 'src/widget.ts', + ), + widgetWorkspaceSymbols: workspaceSymbolView( + await tools.workspaceSymbols('Widget'), fixture.cwd, dependencyRoot, + ), + tsxLabelDefinitions: locationView( + await tools.definitions(tsxLabelTarget), fixture.cwd, dependencyRoot, + ), + unicodeWidgetDefinitions: locationView( + await tools.definitions(unicodeWidgetTarget), fixture.cwd, dependencyRoot, + ), + moduleValueDefinitions: locationView( + await tools.definitions(moduleReferenceTarget), fixture.cwd, dependencyRoot, + ), + commonValueDefinitions: locationView( + await tools.definitions(commonReferenceTarget), fixture.cwd, dependencyRoot, + ), + hoverIdentifiesWidget: widgetHover !== null && JSON.stringify(widgetHover).includes('Widget'), + runCalleesDepth2: callView( + await tools.callees(runLineTarget, { depth: 2 }), fixture.cwd, dependencyRoot, + ), + leafCallersDepth2: callView( + await tools.callers(leafTarget, { depth: 2 }), fixture.cwd, dependencyRoot, + ), + externalDefinitions: locationView( + await tools.definitions(externalTarget), fixture.cwd, dependencyRoot, + ), + externalDependencyDefinitions: locationView( + await tools.definitions(externalTarget, { includeDependencies: true }), fixture.cwd, dependencyRoot, + ), + nonSymbolDefinitions: locationView( + await tools.definitions(nonSymbolTarget), fixture.cwd, dependencyRoot, + ), + }; + + assert.deepEqual(actual, golden); + await assert.rejects(tools.declarations(widgetTarget), { + message: 'LSP server does not support textDocument/declaration', + }); + } finally { + try { + await session.close(); + } finally { + sandboxes.cleanup(); + } + } +}); diff --git a/website/src/content/docs/writing-rules.md b/website/src/content/docs/writing-rules.md index 4c92a1e..94d3f60 100644 --- a/website/src/content/docs/writing-rules.md +++ b/website/src/content/docs/writing-rules.md @@ -126,9 +126,9 @@ 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. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. LSP use is optional; install Go and `gopls` or Shopify Ruby LSP (`ruby-lsp`) on `PATH` only if a rule uses that language's helpers. CodeRuler does not install either server. +`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. `context.rubyLsp` provides Ruby definitions, references, hover, document symbols, and workspace symbols. `context.typescriptLsp` provides TypeScript definitions, type definitions, implementations, references, hover, document symbols, workspace symbols, callers, and callees; declaration lookup is not supported. LSP use is optional. Install Go and `gopls`, Shopify Ruby LSP (`ruby-lsp`), or native TypeScript 7's `tsc` on `PATH` only when a rule uses that language's helpers. CodeRuler does not install these servers. -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 (Go only) 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`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. +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 (Go and TypeScript) 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`. Ruby dependency definitions are excluded by default and can be requested with `includeDependencies: true`. An operation a server does not support rejects with a capability error. 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.