Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions skills/coderuler-new-rule/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 9 additions & 2 deletions src/adapter/filesystem/rule-test-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -39,7 +40,7 @@ function fixturePath(root: string, filepath: string): string {
}

async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOutcome> {
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);
Expand All @@ -60,6 +61,8 @@ async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOu
},
createLspBackend: (_project, signal) => 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');
Expand All @@ -75,7 +78,11 @@ async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOu
try {
lspPort.close();
} finally {
rubyLspPort.close();
try {
rubyLspPort.close();
} finally {
typescriptLspPort.close();
}
}
}
}
Expand Down
28 changes: 22 additions & 6 deletions src/adapter/filesystem/rule-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ interface RuleTestLspPair {
interface ActiveRuleTestLsp {
readonly go: RuleTestLspPair;
readonly ruby: RuleTestLspPair;
readonly typescript: RuleTestLspPair;
transferred: boolean;
closePromise?: Promise<void>;
}
Expand Down Expand Up @@ -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<void> } => host !== undefined,
);
const results = await Promise.allSettled(hosts.map(async host => host.close()));
Expand All @@ -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;
}
Expand All @@ -146,11 +149,12 @@ class RuleTestWorkerSession {
}

async run(
request: Omit<RuleTestWorkerRequest, 'id' | 'lspPort' | 'rubyLspPort'>,
request: Omit<RuleTestWorkerRequest, 'id' | 'lspPort' | 'rubyLspPort' | 'typescriptLspPort'>,
deadline: number,
onFailure: (error: Error) => void,
createBackend?: LspBackendFactory,
createRubyLspBackend?: LspBackendFactory,
createTypescriptLspBackend?: LspBackendFactory,
): Promise<TestActual | RuleTestWorkerOutcome> {
this.onFailure = onFailure;
this.assertHealthy();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -273,6 +286,7 @@ async function runCase(
includeDisabled: boolean,
createLspBackend?: LspBackendFactory,
createRubyLspBackend?: LspBackendFactory,
createTypescriptLspBackend?: LspBackendFactory,
): Promise<RuleTestResult> {
const started = performance.now();
const deadline = started + timeoutMs;
Expand All @@ -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;
Expand Down Expand Up @@ -340,6 +354,7 @@ export async function runRuleTests(
includeDisabled = false,
createLspBackend?: LspBackendFactory,
createRubyLspBackend?: LspBackendFactory,
createTypescriptLspBackend?: LspBackendFactory,
): Promise<readonly RuleTestResult[]> {
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`);
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 18 additions & 3 deletions src/adapter/lsp/backend.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'],
});
Expand Down Expand Up @@ -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;
})();
Expand Down Expand Up @@ -958,3 +965,11 @@ export function createRubyLspBackend(project: Project, signal: AbortSignal): Pro
initializationProgressToken: 'indexing-progress',
});
}

export function createTypescriptLspBackend(project: Project, signal: AbortSignal): Promise<LspBackend> {
return createLspBackend(project, signal, {
command: 'tsc',
args: ['--lsp', '--stdio'],
languageId: 'typescript',
});
}
9 changes: 7 additions & 2 deletions src/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ export async function executeRules(
): Promise<readonly RuleReport[]> {
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<void> => {
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<Promise<unknown>>();
Expand All @@ -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) {
Expand All @@ -130,6 +133,7 @@ async function executeRun(
context: RunContext,
goLsp: LspTools,
rubyLsp: LspTools,
typescriptLsp: LspTools,
scheduleWork: <T>(work: Promise<T>) => Promise<T>,
abortLsp: () => void,
): Promise<readonly RuleReport[]> {
Expand Down Expand Up @@ -160,6 +164,7 @@ async function executeRun(
project: context.project,
goLsp,
rubyLsp,
typescriptLsp,
...(changedFiles === undefined ? {} : { changedFiles }),
...(changedLines === undefined ? {} : { changedLines }),
trace: traceFor(name, evaluationId),
Expand Down
1 change: 1 addition & 0 deletions src/app/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface RunContext {
readonly isTarget: (filepath: string) => boolean;
readonly createLspBackend?: LspBackendFactory;
readonly createRubyLspBackend?: LspBackendFactory;
readonly createTypescriptLspBackend?: LspBackendFactory;
readonly changedFiles?: ReadonlySet<string>;
readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
readonly concurrency: number;
Expand Down
1 change: 1 addition & 0 deletions src/app/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export interface ProjectContext {
readonly project: Project;
readonly goLsp: LspTools;
readonly rubyLsp: LspTools;
readonly typescriptLsp: LspTools;
readonly changedFiles?: ReadonlySet<string>;
readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
trace(message: string, details?: JsonValue): void;
Expand Down
7 changes: 5 additions & 2 deletions src/infra/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<TokenPricing>) =>
settings.createDecisionResolver(env, thresholdOverride, pricingOverrides),
Expand Down Expand Up @@ -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<TokenPricing>) =>
settings.createDecisionResolver(env, thresholdOverride, pricingOverrides),
Expand All @@ -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,
),
};
},
Expand Down
1 change: 1 addition & 0 deletions tests/code-scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const projectContext: ProjectContext = {
project,
goLsp: createRunLsp(project).tools,
rubyLsp: createRunLsp(project).tools,
typescriptLsp: createRunLsp(project).tools,
trace: () => {},
};
async function select(
Expand Down
6 changes: 6 additions & 0 deletions tests/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class TestContext implements CheckContext<JsonValue> {
readonly project: Project;
readonly goLsp: ProjectContext['goLsp'];
readonly rubyLsp: ProjectContext['rubyLsp'];
readonly typescriptLsp: ProjectContext['typescriptLsp'];
readonly ruleName: string;
readonly traces: CapturedTrace[] = [];
readonly selections: unknown[] = [];
Expand All @@ -109,6 +110,7 @@ class TestContext implements CheckContext<JsonValue> {
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;
Expand Down Expand Up @@ -141,6 +143,7 @@ class TestContext implements CheckContext<JsonValue> {
project: this.project,
goLsp: this.goLsp,
rubyLsp: this.rubyLsp,
typescriptLsp: this.typescriptLsp,
ruleName: this.ruleName,
trace: (message, details) => this.trace(message, details),
},
Expand Down Expand Up @@ -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: () => {},
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: () => {},
},
Expand Down
Loading
Loading