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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ jobs:
with:
go-version: '1.27.3'
cache: false
- uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0.7'
- run: gem install ruby-lsp --version 0.26.11 --no-document
- run: bundle install --gemfile tests/fixtures/ruby-lsp/project/Gemfile
- 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
Expand Down
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. 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.
`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.

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`.
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`.

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
10 changes: 8 additions & 2 deletions src/adapter/filesystem/rule-test-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createRuleLoader } from './rules.js';
export interface RuleTestWorkerRequest {
readonly id: number;
readonly lspPort: MessagePort;
readonly rubyLspPort: MessagePort;
readonly root: string;
readonly ruleFile: string;
readonly files: RuleTestCase['files'];
Expand All @@ -38,7 +39,7 @@ function fixturePath(root: string, filepath: string): string {
}

async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOutcome> {
const { lspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request;
const { lspPort, rubyLspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request;
try {
for (const [filepath, source] of Object.entries(files)) {
const target = fixturePath(root, filepath);
Expand All @@ -58,6 +59,7 @@ async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOu
throw new Error('Inline rule tests must not call the decision backend');
},
createLspBackend: (_project, signal) => createRuleTestLspBackend(lspPort, signal),
createRubyLspBackend: (_project, signal) => createRuleTestLspBackend(rubyLspPort, signal),
});
const report = reports[0];
if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report');
Expand All @@ -70,7 +72,11 @@ async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOu
}
return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
} finally {
lspPort.close();
try {
lspPort.close();
} finally {
rubyLspPort.close();
}
}
}

Expand Down
63 changes: 44 additions & 19 deletions src/adapter/filesystem/rule-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,14 @@ function addTestGroup(

type TestActual = RuleTestResult['actual'];

interface ActiveRuleTestLsp {
interface RuleTestLspPair {
readonly channel: MessageChannel;
readonly host: { close(): Promise<void> };
host: { close(): Promise<void> } | undefined;
}

interface ActiveRuleTestLsp {
readonly go: RuleTestLspPair;
readonly ruby: RuleTestLspPair;
transferred: boolean;
closePromise?: Promise<void>;
}
Expand Down Expand Up @@ -121,21 +126,31 @@ class RuleTestWorkerSession {
if (active.closePromise !== undefined) return active.closePromise;
active.closePromise = (async () => {
try {
await active.host.close();
const hosts = [active.go.host, active.ruby.host].filter(
(host): host is { close(): Promise<void> } => host !== undefined,
);
const results = await Promise.allSettled(hosts.map(async host => host.close()));
const rejected = results.find(result => result.status === 'rejected');
if (rejected?.status === 'rejected') throw rejected.reason;
} finally {
active.channel.port1.close();
if (!active.transferred) active.channel.port2.close();
active.go.channel.port1.close();
active.ruby.channel.port1.close();
if (!active.transferred) {
active.go.channel.port2.close();
active.ruby.channel.port2.close();
}
if (this.activeLsp === active) this.activeLsp = undefined;
}
})();
return active.closePromise;
}

async run(
request: Omit<RuleTestWorkerRequest, 'id' | 'lspPort'>,
request: Omit<RuleTestWorkerRequest, 'id' | 'lspPort' | 'rubyLspPort'>,
deadline: number,
onFailure: (error: Error) => void,
createBackend?: LspBackendFactory,
createRubyLspBackend?: LspBackendFactory,
): Promise<TestActual | RuleTestWorkerOutcome> {
this.onFailure = onFailure;
this.assertHealthy();
Expand Down Expand Up @@ -163,24 +178,32 @@ class RuleTestWorkerSession {
try {
expire();
if (performance.now() < deadline) {
const channel = new MessageChannel();
const channels: MessageChannel[] = [];
try {
const host = createRuleTestLspHost(
channel.port1,
createProject(request.root, { excludeFiles: [request.ruleFile] }),
createBackend,
);
activeLsp = { channel, host, transferred: false };
const goChannel = new MessageChannel();
channels.push(goChannel);
const rubyChannel = new MessageChannel();
channels.push(rubyChannel);
activeLsp = {
go: { channel: goChannel, host: undefined },
ruby: { channel: rubyChannel, 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);
this.worker.postMessage(
{ ...request, id, lspPort: channel.port2 } satisfies RuleTestWorkerRequest,
[channel.port2],
{ ...request, id, lspPort: goChannel.port2, rubyLspPort: rubyChannel.port2 } satisfies RuleTestWorkerRequest,
[goChannel.port2, rubyChannel.port2],
);
activeLsp.transferred = true;
} catch (error) {
if (activeLsp === undefined) {
channel.port1.close();
channel.port2.close();
for (const channel of channels) {
channel.port1.close();
channel.port2.close();
}
}
throw error;
}
Expand Down Expand Up @@ -249,6 +272,7 @@ async function runCase(
onFailure: (error: unknown) => void,
includeDisabled: boolean,
createLspBackend?: LspBackendFactory,
createRubyLspBackend?: LspBackendFactory,
): Promise<RuleTestResult> {
const started = performance.now();
const deadline = started + timeoutMs;
Expand All @@ -275,7 +299,7 @@ async function runCase(
root, ruleFile: entry.ruleFile, files: example.files, ai,
...(includeDisabled ? { includeDisabled: true } : {}),
...(dependencies === undefined ? {} : { dependencies }),
}, deadline, failCase, createLspBackend);
}, deadline, failCase, createLspBackend, createRubyLspBackend);
if (typeof outcome === 'string') actual = outcome;
else {
actual = outcome.actual;
Expand Down Expand Up @@ -315,6 +339,7 @@ export async function runRuleTests(
includeAI = false,
includeDisabled = false,
createLspBackend?: LspBackendFactory,
createRubyLspBackend?: 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 @@ -375,7 +400,7 @@ 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,
rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, createRubyLspBackend,
);
if (failureObserved) return;
while (completed[nextResult] !== undefined) {
Expand Down
Loading
Loading