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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jobs:
with:
node-version: ${{ matrix.node }}
cache: npm
- uses: actions/setup-go@v7
with:
go-version: '1.27.3'
cache: false
- run: GOBIN="$RUNNER_TEMP/gopls-bin" go install golang.org/x/tools/gopls@v0.24.0
- run: echo "$RUNNER_TEMP/gopls-bin" >> "$GITHUB_PATH"
- run: npm ci
- run: npm test
env:
Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"skills": "1.7.2",
"ssri": "14.0.0",
"string-width": "8.3.0",
"vscode-languageserver-protocol": "3.18.5",
"yaml": "^2.9.1"
},
"devDependencies": {
Expand Down
36 changes: 35 additions & 1 deletion skills/coderuler-new-rule/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,43 @@ export default class Local extends Rule {
}
```


Use `trace` for decision evidence visible during inspection; keep normal execution free of ad-hoc console output. AI/regex projection callbacks receive only `ProjectContext`, not the full check context.

`context.goLsp` provides Go-to-definition, declaration, type-definition, implementation, reference, hover, document-symbol, workspace-symbol, caller, and callee queries. LSP use is optional; install Go and `gopls` only if a rule calls these helpers. Declaration lookup is part of the cross-backend API, but gopls v0.24.0 does not support it, so that query rejects.

Pass file paths and positive, 1-based line and UTF-16 column coordinates. A column counts UTF-16 code units. Navigation and hover use column 1 when omitted. Caller and callee queries use the enclosing named function or method when the column is omitted. Their required `depth` is a nonnegative integer; depth 1 returns direct neighbors. Set `includeDependencies: true` to include available dependency or standard-library source outside the project; the default is `false`.

Caller and callee results are a static call graph. They can omit dynamic calls and do not load every possible package consumer. Use them as the server's known call relationships, not as a complete runtime graph.

Call graph helpers from an async custom check:

```ts
import { Rule, pass, scopes, violation } from '@alex-dbs/coderuler';
import type { Evaluator } from '@alex-dbs/coderuler';

export default class CallGraphCheck extends Rule {
files = ['**/*.go'];
scope = scopes.astGrep({
language: 'go',
rule: { any: [{ kind: 'function_declaration' }, { kind: 'method_declaration' }] },
});

check: Evaluator = async (subject, context) => {
if (subject.kind !== 'scope' || subject.lines === undefined) return pass();
const target = { filepath: subject.filepath, line: subject.lines.start };
const [callers, callees] = await Promise.all([
context.goLsp.callers(target, { depth: 2 }),
context.goLsp.callees(target, { depth: 2, includeDependencies: true }),
]);
return callers.length + callees.length === 0
? pass()
: violation('Review callers and callees');
};
}
```

The example assumes `pass` and `violation` are imported from the SDK.

## Regex: matching means passing

`regex<T = JsonValue>(options: RegexOptions<T>): Evaluator<T>` takes `{ readonly pattern: RegExp; readonly violationMessage?: string; readonly input?: (subject: ScopeSubject<T>, context: ProjectContext) => Awaitable<string> }`.
Expand Down
189 changes: 189 additions & 0 deletions src/adapter/filesystem/rule-test-lsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type { MessagePort } from 'node:worker_threads';

import { createRunLsp } from '../../app/lsp.js';
import type { LspTools } from '../../app/rule.js';
import type { LspBackend, LspBackendFactory, Project } from '../../app/ports.js';

type LspMethod = keyof LspTools;
type RpcMethod = LspMethod | 'close';
type RpcCall<Method extends LspMethod> = {
readonly id: number;
readonly method: Method;
readonly args: Parameters<LspTools[Method]>;
};
type RpcRequest = { [Method in LspMethod]: RpcCall<Method> }[LspMethod]
| { readonly id: number; readonly method: 'close'; readonly args: [] };
type RpcResponse =
| { readonly id: number; readonly result: unknown }
| { readonly id: number; readonly error: string };
type RpcArgs<Method extends RpcMethod> = Method extends LspMethod
? Parameters<LspTools[Method]>
: [];
type RpcResult<Method extends RpcMethod> = Method extends LspMethod
? Awaited<ReturnType<LspTools[Method]>>
: void;

function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function send(port: MessagePort, response: RpcResponse): void {
try {
port.postMessage(response);
} catch {
// A peer can close its port while an operation is settling.
}
}

export function createRuleTestLspHost(
port: MessagePort,
project: Project,
createBackend?: LspBackendFactory,
): { close(): Promise<void> } {
const facade = createRunLsp(project, createBackend);
let closed = false;
let closePromise: Promise<void> | undefined;

const onRequest = (value: unknown): void => {
if (value === null || typeof value !== 'object') return;
const candidate = value as { readonly id?: unknown; readonly args?: unknown };
if (!Number.isSafeInteger(candidate.id) || !Array.isArray(candidate.args)) return;
const request = value as RpcRequest;
void (async () => {
try {
let result: unknown;
switch (request.method) {
case 'definitions': result = await facade.tools.definitions(...request.args); break;
case 'declarations': result = await facade.tools.declarations(...request.args); break;
case 'typeDefinitions': result = await facade.tools.typeDefinitions(...request.args); break;
case 'implementations': result = await facade.tools.implementations(...request.args); break;
case 'references': result = await facade.tools.references(...request.args); break;
case 'hover': result = await facade.tools.hover(...request.args); break;
case 'documentSymbols': result = await facade.tools.documentSymbols(...request.args); break;
case 'workspaceSymbols': result = await facade.tools.workspaceSymbols(...request.args); break;
case 'callers': result = await facade.tools.callers(...request.args); break;
case 'callees': result = await facade.tools.callees(...request.args); break;
case 'close': await facade.close(); result = undefined; break;
default: throw new Error('Unsupported rule-test LSP method');
}
send(port, { id: request.id, result });
} catch (error) {
send(port, { id: request.id, error: describe(error) });
}
})();
};
const close = (): Promise<void> => {
if (closePromise !== undefined) return closePromise;
closed = true;
port.off('message', onRequest);
closePromise = (async () => {
try {
await facade.close();
} finally {
port.close();
}
})();
return closePromise;
};
const onPortClose = (): void => {
if (!closed) void close().catch(() => undefined);
};

port.on('message', onRequest);
port.once('close', onPortClose);
port.start();
return { close };
}

export function createRuleTestLspBackend(port: MessagePort, signal: AbortSignal): LspBackend {
let nextId = 0;
let portClosed = false;
let signalAborted = false;
let closePromise: Promise<void> | undefined;
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();

const rejectPending = (error: Error): void => {
for (const request of pending.values()) request.reject(error);
pending.clear();
};
const onResponse = (value: unknown): void => {
if (value === null || typeof value !== 'object') return;
const candidate = value as { readonly id?: unknown };
if (!Number.isSafeInteger(candidate.id)) return;
const response = value as RpcResponse;
const request = pending.get(response.id);
if (request === undefined) return;
pending.delete(response.id);
if ('error' in response) request.reject(new Error(response.error));
else request.resolve(response.result);
};
const onPortClose = (): void => {
if (portClosed) return;
portClosed = true;
rejectPending(new Error('Rule test LSP host closed'));
};
const onAbort = (): void => {
signalAborted = true;
rejectPending(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted'));
};

port.on('message', onResponse);
port.once('close', onPortClose);
port.start();
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();

const request = <Method extends RpcMethod>(
method: Method,
args: RpcArgs<Method>,
allowAborted = false,
): Promise<RpcResult<Method>> => {
if (portClosed) return Promise.reject(new Error('Rule test LSP host closed'));
if (signalAborted && !allowAborted) {
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted'));
}
const id = nextId++;
const deferred = Promise.withResolvers<RpcResult<Method>>();
pending.set(id, {
resolve: value => deferred.resolve(value as RpcResult<Method>),
reject: deferred.reject,
});
try {
port.postMessage({ id, method, args } as RpcRequest);
} catch (error) {
pending.delete(id);
deferred.reject(error instanceof Error ? error : new Error(describe(error)));
}
return deferred.promise;
};

const close = (): Promise<void> => {
if (closePromise !== undefined) return closePromise;
closePromise = (async () => {
try {
await request('close', [], true);
} finally {
signal.removeEventListener('abort', onAbort);
port.off('message', onResponse);
port.off('close', onPortClose);
port.close();
onPortClose();
}
})();
return closePromise;
};

return {
definitions: (target, options) => request('definitions', [target, options]),
declarations: (target, options) => request('declarations', [target, options]),
typeDefinitions: (target, options) => request('typeDefinitions', [target, options]),
implementations: (target, options) => request('implementations', [target, options]),
references: (target, options) => request('references', [target, options]),
hover: (target, options) => request('hover', [target, options]),
documentSymbols: (filepath, options) => request('documentSymbols', [filepath, options]),
workspaceSymbols: (query, options) => request('workspaceSymbols', [query, options]),
callers: (target, options) => request('callers', [target, options]),
callees: (target, options) => request('callees', [target, options]),
close,
};
}
66 changes: 37 additions & 29 deletions src/adapter/filesystem/rule-test-worker.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, resolve, sep, win32 } from 'node:path';
import { parentPort } from 'node:worker_threads';
import { parentPort, type MessagePort } from 'node:worker_threads';

import { executeRules } from '../../app/execute.js';
import type { RuleTestCase } from '../../app/rule-tests.js';
import { createDecisionResolver } from '../../infra/settings.js';
import { createProject } from './project.js';
import type { PreparedRuleDependencies } from './dependencies.js';
import { createRuleTestLspBackend } from './rule-test-lsp.js';
import { createProject } from './project.js';
import { createRuleLoader } from './rules.js';

export interface RuleTestWorkerRequest {
readonly id: number;
readonly lspPort: MessagePort;
readonly root: string;
readonly ruleFile: string;
readonly files: RuleTestCase['files'];
Expand All @@ -35,35 +37,41 @@ function fixturePath(root: string, filepath: string): string {
return target;
}

async function runCase({ root, ruleFile, files, ai, dependencies, includeDisabled }: RuleTestWorkerRequest): Promise<RuleTestWorkerOutcome> {
for (const [filepath, source] of Object.entries(files)) {
const target = fixturePath(root, filepath);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8');
}
const rules = await createRuleLoader(
dependencies === undefined ? {} : { preparedDependencies: dependencies },
).load({ root, explicit: ruleFile, invocationCwd: root });
if (rules.length !== 1) throw new Error('Expected exactly one freshly loaded rule');
if (includeDisabled === true) rules[0]!.rule.enabled = true;
const reports = await executeRules(rules, {
project: createProject(root, { excludeFiles: [ruleFile] }),
isTarget: () => true,
concurrency: 1,
resolveDecision: ai ? createDecisionResolver(process.env) : () => {
throw new Error('Inline rule tests must not call the decision backend');
},
});
const report = reports[0];
if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report');
let aiScores: number[] | undefined;
if (ai) {
for (const evaluation of report.evaluations) {
const score = evaluation.result.probability;
if (score !== undefined) (aiScores ??= []).push(score);
async function runCase(request: RuleTestWorkerRequest): Promise<RuleTestWorkerOutcome> {
const { lspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request;
try {
for (const [filepath, source] of Object.entries(files)) {
const target = fixturePath(root, filepath);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8');
}
const rules = await createRuleLoader(
dependencies === undefined ? {} : { preparedDependencies: dependencies },
).load({ root, explicit: ruleFile, invocationCwd: root });
if (rules.length !== 1) throw new Error('Expected exactly one freshly loaded rule');
if (includeDisabled === true) rules[0]!.rule.enabled = true;
const reports = await executeRules(rules, {
project: createProject(root, { excludeFiles: [ruleFile] }),
isTarget: () => true,
concurrency: 1,
resolveDecision: ai ? createDecisionResolver(process.env) : () => {
throw new Error('Inline rule tests must not call the decision backend');
},
createLspBackend: (_project, signal) => createRuleTestLspBackend(lspPort, signal),
});
const report = reports[0];
if (reports.length !== 1 || report === undefined) throw new Error('Expected exactly one rule report');
let aiScores: number[] | undefined;
if (ai) {
for (const evaluation of report.evaluations) {
const score = evaluation.result.probability;
if (score !== undefined) (aiScores ??= []).push(score);
}
}
return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
} finally {
lspPort.close();
}
return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
}

const port = parentPort;
Expand Down
Loading
Loading