@alex-dbs/coderuler 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ import type { MessagePort } from 'node:worker_threads';
2
+ import type { LspBackend, LspBackendFactory, Project } from '../../app/ports.js';
3
+ export declare function createRuleTestLspHost(port: MessagePort, project: Project, createBackend?: LspBackendFactory): {
4
+ close(): Promise<void>;
5
+ };
6
+ export declare function createRuleTestLspBackend(port: MessagePort, signal: AbortSignal): LspBackend;
@@ -0,0 +1,189 @@
1
+ import { createRunLsp } from '../../app/lsp.js';
2
+ function describe(error) {
3
+ return error instanceof Error ? error.message : String(error);
4
+ }
5
+ function send(port, response) {
6
+ try {
7
+ port.postMessage(response);
8
+ }
9
+ catch {
10
+ // A peer can close its port while an operation is settling.
11
+ }
12
+ }
13
+ export function createRuleTestLspHost(port, project, createBackend) {
14
+ const facade = createRunLsp(project, createBackend);
15
+ let closed = false;
16
+ let closePromise;
17
+ const onRequest = (value) => {
18
+ if (value === null || typeof value !== 'object')
19
+ return;
20
+ const candidate = value;
21
+ if (!Number.isSafeInteger(candidate.id) || !Array.isArray(candidate.args))
22
+ return;
23
+ const request = value;
24
+ void (async () => {
25
+ try {
26
+ let result;
27
+ switch (request.method) {
28
+ case 'definitions':
29
+ result = await facade.tools.definitions(...request.args);
30
+ break;
31
+ case 'declarations':
32
+ result = await facade.tools.declarations(...request.args);
33
+ break;
34
+ case 'typeDefinitions':
35
+ result = await facade.tools.typeDefinitions(...request.args);
36
+ break;
37
+ case 'implementations':
38
+ result = await facade.tools.implementations(...request.args);
39
+ break;
40
+ case 'references':
41
+ result = await facade.tools.references(...request.args);
42
+ break;
43
+ case 'hover':
44
+ result = await facade.tools.hover(...request.args);
45
+ break;
46
+ case 'documentSymbols':
47
+ result = await facade.tools.documentSymbols(...request.args);
48
+ break;
49
+ case 'workspaceSymbols':
50
+ result = await facade.tools.workspaceSymbols(...request.args);
51
+ break;
52
+ case 'callers':
53
+ result = await facade.tools.callers(...request.args);
54
+ break;
55
+ case 'callees':
56
+ result = await facade.tools.callees(...request.args);
57
+ break;
58
+ case 'close':
59
+ await facade.close();
60
+ result = undefined;
61
+ break;
62
+ default: throw new Error('Unsupported rule-test LSP method');
63
+ }
64
+ send(port, { id: request.id, result });
65
+ }
66
+ catch (error) {
67
+ send(port, { id: request.id, error: describe(error) });
68
+ }
69
+ })();
70
+ };
71
+ const close = () => {
72
+ if (closePromise !== undefined)
73
+ return closePromise;
74
+ closed = true;
75
+ port.off('message', onRequest);
76
+ closePromise = (async () => {
77
+ try {
78
+ await facade.close();
79
+ }
80
+ finally {
81
+ port.close();
82
+ }
83
+ })();
84
+ return closePromise;
85
+ };
86
+ const onPortClose = () => {
87
+ if (!closed)
88
+ void close().catch(() => undefined);
89
+ };
90
+ port.on('message', onRequest);
91
+ port.once('close', onPortClose);
92
+ port.start();
93
+ return { close };
94
+ }
95
+ export function createRuleTestLspBackend(port, signal) {
96
+ let nextId = 0;
97
+ let portClosed = false;
98
+ let signalAborted = false;
99
+ let closePromise;
100
+ const pending = new Map();
101
+ const rejectPending = (error) => {
102
+ for (const request of pending.values())
103
+ request.reject(error);
104
+ pending.clear();
105
+ };
106
+ const onResponse = (value) => {
107
+ if (value === null || typeof value !== 'object')
108
+ return;
109
+ const candidate = value;
110
+ if (!Number.isSafeInteger(candidate.id))
111
+ return;
112
+ const response = value;
113
+ const request = pending.get(response.id);
114
+ if (request === undefined)
115
+ return;
116
+ pending.delete(response.id);
117
+ if ('error' in response)
118
+ request.reject(new Error(response.error));
119
+ else
120
+ request.resolve(response.result);
121
+ };
122
+ const onPortClose = () => {
123
+ if (portClosed)
124
+ return;
125
+ portClosed = true;
126
+ rejectPending(new Error('Rule test LSP host closed'));
127
+ };
128
+ const onAbort = () => {
129
+ signalAborted = true;
130
+ rejectPending(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted'));
131
+ };
132
+ port.on('message', onResponse);
133
+ port.once('close', onPortClose);
134
+ port.start();
135
+ signal.addEventListener('abort', onAbort, { once: true });
136
+ if (signal.aborted)
137
+ onAbort();
138
+ const request = (method, args, allowAborted = false) => {
139
+ if (portClosed)
140
+ return Promise.reject(new Error('Rule test LSP host closed'));
141
+ if (signalAborted && !allowAborted) {
142
+ return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Rule test LSP backend aborted'));
143
+ }
144
+ const id = nextId++;
145
+ const deferred = Promise.withResolvers();
146
+ pending.set(id, {
147
+ resolve: value => deferred.resolve(value),
148
+ reject: deferred.reject,
149
+ });
150
+ try {
151
+ port.postMessage({ id, method, args });
152
+ }
153
+ catch (error) {
154
+ pending.delete(id);
155
+ deferred.reject(error instanceof Error ? error : new Error(describe(error)));
156
+ }
157
+ return deferred.promise;
158
+ };
159
+ const close = () => {
160
+ if (closePromise !== undefined)
161
+ return closePromise;
162
+ closePromise = (async () => {
163
+ try {
164
+ await request('close', [], true);
165
+ }
166
+ finally {
167
+ signal.removeEventListener('abort', onAbort);
168
+ port.off('message', onResponse);
169
+ port.off('close', onPortClose);
170
+ port.close();
171
+ onPortClose();
172
+ }
173
+ })();
174
+ return closePromise;
175
+ };
176
+ return {
177
+ definitions: (target, options) => request('definitions', [target, options]),
178
+ declarations: (target, options) => request('declarations', [target, options]),
179
+ typeDefinitions: (target, options) => request('typeDefinitions', [target, options]),
180
+ implementations: (target, options) => request('implementations', [target, options]),
181
+ references: (target, options) => request('references', [target, options]),
182
+ hover: (target, options) => request('hover', [target, options]),
183
+ documentSymbols: (filepath, options) => request('documentSymbols', [filepath, options]),
184
+ workspaceSymbols: (query, options) => request('workspaceSymbols', [query, options]),
185
+ callers: (target, options) => request('callers', [target, options]),
186
+ callees: (target, options) => request('callees', [target, options]),
187
+ close,
188
+ };
189
+ }
@@ -1,7 +1,11 @@
1
+ import { type MessagePort } from 'node:worker_threads';
1
2
  import type { RuleTestCase } from '../../app/rule-tests.js';
2
3
  import type { PreparedRuleDependencies } from './dependencies.js';
3
4
  export interface RuleTestWorkerRequest {
4
5
  readonly id: number;
6
+ readonly lspPort: MessagePort;
7
+ readonly rubyLspPort: MessagePort;
8
+ readonly typescriptLspPort: MessagePort;
5
9
  readonly root: string;
6
10
  readonly ruleFile: string;
7
11
  readonly files: RuleTestCase['files'];
@@ -3,6 +3,7 @@ import { dirname, isAbsolute, resolve, sep, win32 } from 'node:path';
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { executeRules } from '../../app/execute.js';
5
5
  import { createDecisionResolver } from '../../infra/settings.js';
6
+ import { createRuleTestLspBackend } from './rule-test-lsp.js';
6
7
  import { createProject } from './project.js';
7
8
  import { createRuleLoader } from './rules.js';
8
9
  function fixturePath(root, filepath) {
@@ -12,37 +13,56 @@ function fixturePath(root, filepath) {
12
13
  }
13
14
  return target;
14
15
  }
15
- async function runCase({ root, ruleFile, files, ai, dependencies, includeDisabled }) {
16
- for (const [filepath, source] of Object.entries(files)) {
17
- const target = fixturePath(root, filepath);
18
- await mkdir(dirname(target), { recursive: true });
19
- await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8');
16
+ async function runCase(request) {
17
+ const { lspPort, rubyLspPort, typescriptLspPort, root, ruleFile, files, ai, dependencies, includeDisabled } = request;
18
+ try {
19
+ for (const [filepath, source] of Object.entries(files)) {
20
+ const target = fixturePath(root, filepath);
21
+ await mkdir(dirname(target), { recursive: true });
22
+ await writeFile(target, typeof source === 'string' ? source : JSON.stringify(source, null, 2), 'utf8');
23
+ }
24
+ const rules = await createRuleLoader(dependencies === undefined ? {} : { preparedDependencies: dependencies }).load({ root, explicit: ruleFile, invocationCwd: root });
25
+ if (rules.length !== 1)
26
+ throw new Error('Expected exactly one freshly loaded rule');
27
+ if (includeDisabled === true)
28
+ rules[0].rule.enabled = true;
29
+ const reports = await executeRules(rules, {
30
+ project: createProject(root, { excludeFiles: [ruleFile] }),
31
+ isTarget: () => true,
32
+ concurrency: 1,
33
+ resolveDecision: ai ? createDecisionResolver(process.env) : () => {
34
+ throw new Error('Inline rule tests must not call the decision backend');
35
+ },
36
+ createLspBackend: (_project, signal) => createRuleTestLspBackend(lspPort, signal),
37
+ createRubyLspBackend: (_project, signal) => createRuleTestLspBackend(rubyLspPort, signal),
38
+ createTypescriptLspBackend: (_project, signal) => createRuleTestLspBackend(typescriptLspPort, signal),
39
+ });
40
+ const report = reports[0];
41
+ if (reports.length !== 1 || report === undefined)
42
+ throw new Error('Expected exactly one rule report');
43
+ let aiScores;
44
+ if (ai) {
45
+ for (const evaluation of report.evaluations) {
46
+ const score = evaluation.result.probability;
47
+ if (score !== undefined)
48
+ (aiScores ??= []).push(score);
49
+ }
50
+ }
51
+ return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
20
52
  }
21
- const rules = await createRuleLoader(dependencies === undefined ? {} : { preparedDependencies: dependencies }).load({ root, explicit: ruleFile, invocationCwd: root });
22
- if (rules.length !== 1)
23
- throw new Error('Expected exactly one freshly loaded rule');
24
- if (includeDisabled === true)
25
- rules[0].rule.enabled = true;
26
- const reports = await executeRules(rules, {
27
- project: createProject(root, { excludeFiles: [ruleFile] }),
28
- isTarget: () => true,
29
- concurrency: 1,
30
- resolveDecision: ai ? createDecisionResolver(process.env) : () => {
31
- throw new Error('Inline rule tests must not call the decision backend');
32
- },
33
- });
34
- const report = reports[0];
35
- if (reports.length !== 1 || report === undefined)
36
- throw new Error('Expected exactly one rule report');
37
- let aiScores;
38
- if (ai) {
39
- for (const evaluation of report.evaluations) {
40
- const score = evaluation.result.probability;
41
- if (score !== undefined)
42
- (aiScores ??= []).push(score);
53
+ finally {
54
+ try {
55
+ lspPort.close();
56
+ }
57
+ finally {
58
+ try {
59
+ rubyLspPort.close();
60
+ }
61
+ finally {
62
+ typescriptLspPort.close();
63
+ }
43
64
  }
44
65
  }
45
- return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
46
66
  }
47
67
  const port = parentPort;
48
68
  if (port === null)
@@ -1,3 +1,3 @@
1
- import type { LoadedRule } from '../../app/ports.js';
1
+ import type { LoadedRule, LspBackendFactory } from '../../app/ports.js';
2
2
  import type { RuleTestResult } from '../../app/rule-tests.js';
3
- export declare function runRuleTests(rules: readonly LoadedRule[], onResult?: (result: RuleTestResult, lastInRule: boolean) => void, timeoutMs?: number, concurrency?: number, includeAI?: boolean, includeDisabled?: boolean): Promise<readonly RuleTestResult[]>;
3
+ export declare function runRuleTests(rules: readonly LoadedRule[], onResult?: (result: RuleTestResult, lastInRule: boolean) => void, timeoutMs?: number, concurrency?: number, includeAI?: boolean, includeDisabled?: boolean, createLspBackend?: LspBackendFactory, createRubyLspBackend?: LspBackendFactory, createTypescriptLspBackend?: LspBackendFactory): Promise<readonly RuleTestResult[]>;
@@ -2,8 +2,10 @@ import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { Worker } from 'node:worker_threads';
5
+ import { MessageChannel, Worker } from 'node:worker_threads';
6
6
  import { isRecord, validateJsonValue } from '../../app/validate.js';
7
+ import { createRuleTestLspHost } from './rule-test-lsp.js';
8
+ import { createProject } from './project.js';
7
9
  import { getRuleDependencies, registerRuleDependencies } from './rule-resolution.js';
8
10
  // A JavaScript entry lets inherited tsx hooks resolve the source worker's .js import.
9
11
  const WORKER_MODULE = new URL('./rule-test-worker.js', import.meta.url);
@@ -61,6 +63,7 @@ class RuleTestWorkerSession {
61
63
  onFailure;
62
64
  terminating = false;
63
65
  termination;
66
+ activeLsp;
64
67
  onError = (error) => {
65
68
  if (this.terminating)
66
69
  return;
@@ -83,12 +86,41 @@ class RuleTestWorkerSession {
83
86
  if (this.failure !== undefined)
84
87
  throw this.failure;
85
88
  }
86
- async run(request, deadline, onFailure) {
89
+ closeLsp(active = this.activeLsp) {
90
+ if (active === undefined)
91
+ return Promise.resolve();
92
+ if (active.closePromise !== undefined)
93
+ return active.closePromise;
94
+ active.closePromise = (async () => {
95
+ try {
96
+ const hosts = [active.go.host, active.ruby.host, active.typescript.host].filter((host) => host !== undefined);
97
+ const results = await Promise.allSettled(hosts.map(async (host) => host.close()));
98
+ const rejected = results.find(result => result.status === 'rejected');
99
+ if (rejected?.status === 'rejected')
100
+ throw rejected.reason;
101
+ }
102
+ finally {
103
+ active.go.channel.port1.close();
104
+ active.ruby.channel.port1.close();
105
+ active.typescript.channel.port1.close();
106
+ if (!active.transferred) {
107
+ active.go.channel.port2.close();
108
+ active.ruby.channel.port2.close();
109
+ active.typescript.channel.port2.close();
110
+ }
111
+ if (this.activeLsp === active)
112
+ this.activeLsp = undefined;
113
+ }
114
+ })();
115
+ return active.closePromise;
116
+ }
117
+ async run(request, deadline, onFailure, createBackend, createRubyLspBackend, createTypescriptLspBackend) {
87
118
  this.onFailure = onFailure;
88
119
  this.assertHealthy();
89
120
  const id = this.nextId++;
90
121
  const result = Promise.withResolvers();
91
122
  let timer;
123
+ let activeLsp;
92
124
  const expire = () => {
93
125
  const remaining = deadline - performance.now();
94
126
  if (remaining <= 0)
@@ -112,29 +144,94 @@ class RuleTestWorkerSession {
112
144
  else
113
145
  result.reject(error);
114
146
  };
147
+ let operationFailed = false;
115
148
  this.worker.on('message', onMessage);
116
149
  try {
117
150
  expire();
118
151
  if (performance.now() < deadline) {
119
- this.worker.postMessage({ ...request, id });
152
+ const channels = [];
153
+ try {
154
+ const goChannel = new MessageChannel();
155
+ channels.push(goChannel);
156
+ const rubyChannel = new MessageChannel();
157
+ channels.push(rubyChannel);
158
+ const typescriptChannel = new MessageChannel();
159
+ channels.push(typescriptChannel);
160
+ activeLsp = {
161
+ go: { channel: goChannel, host: undefined },
162
+ ruby: { channel: rubyChannel, host: undefined },
163
+ typescript: { channel: typescriptChannel, host: undefined },
164
+ transferred: false,
165
+ };
166
+ this.activeLsp = activeLsp;
167
+ const project = createProject(request.root, { excludeFiles: [request.ruleFile] });
168
+ activeLsp.go.host = createRuleTestLspHost(goChannel.port1, project, createBackend);
169
+ activeLsp.ruby.host = createRuleTestLspHost(rubyChannel.port1, project, createRubyLspBackend);
170
+ activeLsp.typescript.host = createRuleTestLspHost(typescriptChannel.port1, project, createTypescriptLspBackend);
171
+ this.worker.postMessage({
172
+ ...request, id, lspPort: goChannel.port2, rubyLspPort: rubyChannel.port2,
173
+ typescriptLspPort: typescriptChannel.port2,
174
+ }, [goChannel.port2, rubyChannel.port2, typescriptChannel.port2]);
175
+ activeLsp.transferred = true;
176
+ }
177
+ catch (error) {
178
+ if (activeLsp === undefined) {
179
+ for (const channel of channels) {
180
+ channel.port1.close();
181
+ channel.port2.close();
182
+ }
183
+ }
184
+ throw error;
185
+ }
120
186
  }
121
187
  return await result.promise;
122
188
  }
189
+ catch (error) {
190
+ operationFailed = true;
191
+ throw error;
192
+ }
123
193
  finally {
124
194
  clearTimeout(timer);
125
195
  this.worker.off('message', onMessage);
126
196
  this.rejectPending = undefined;
197
+ try {
198
+ await this.closeLsp(activeLsp);
199
+ }
200
+ catch (error) {
201
+ if (!operationFailed)
202
+ throw error;
203
+ if (activeLsp !== undefined)
204
+ activeLsp.closePromise = Promise.resolve();
205
+ }
127
206
  }
128
207
  }
129
208
  async stop() {
130
209
  if (this.termination === undefined) {
131
210
  this.terminating = true;
132
211
  this.rejectPending?.(new Error('Rule test worker stopped'));
133
- this.termination = this.worker.terminate();
212
+ this.termination = (async () => {
213
+ let closeFailed = false;
214
+ let closeError;
215
+ try {
216
+ await this.closeLsp();
217
+ }
218
+ catch (error) {
219
+ closeFailed = true;
220
+ closeError = error;
221
+ }
222
+ const code = await this.worker.terminate();
223
+ if (closeFailed)
224
+ throw closeError;
225
+ return code;
226
+ })();
227
+ }
228
+ try {
229
+ await this.termination;
230
+ }
231
+ finally {
232
+ this.worker.off('error', this.onError);
233
+ this.worker.off('exit', this.onExit);
134
234
  }
135
- await this.termination;
136
- this.worker.off('error', this.onError);
137
- this.worker.off('exit', this.onExit);
138
235
  }
139
236
  }
140
237
  async function stopSession(session) {
@@ -143,7 +240,7 @@ async function stopSession(session) {
143
240
  if (session.current === current)
144
241
  session.current = undefined;
145
242
  }
146
- async function runCase(entry, example, ai, timeoutMs, session, onFailure, includeDisabled) {
243
+ async function runCase(entry, example, ai, timeoutMs, session, onFailure, includeDisabled, createLspBackend, createRubyLspBackend, createTypescriptLspBackend) {
147
244
  const started = performance.now();
148
245
  const deadline = started + timeoutMs;
149
246
  let failure;
@@ -172,7 +269,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure, includ
172
269
  root, ruleFile: entry.ruleFile, files: example.files, ai,
173
270
  ...(includeDisabled ? { includeDisabled: true } : {}),
174
271
  ...(dependencies === undefined ? {} : { dependencies }),
175
- }, deadline, failCase);
272
+ }, deadline, failCase, createLspBackend, createRubyLspBackend, createTypescriptLspBackend);
176
273
  if (typeof outcome === 'string')
177
274
  actual = outcome;
178
275
  else {
@@ -210,7 +307,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure, includ
210
307
  throw cause;
211
308
  }
212
309
  }
213
- export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false, includeDisabled = false) {
310
+ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false, includeDisabled = false, createLspBackend, createRubyLspBackend, createTypescriptLspBackend) {
214
311
  if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
215
312
  throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`);
216
313
  }
@@ -273,7 +370,7 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
273
370
  }
274
371
  const group = groups[groupIndex];
275
372
  const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`);
276
- completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled);
373
+ completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, createRubyLspBackend, createTypescriptLspBackend);
277
374
  if (failureObserved)
278
375
  return;
279
376
  while (completed[nextResult] !== undefined) {
@@ -307,6 +404,12 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
307
404
  }
308
405
  finally {
309
406
  await cancellation;
310
- await Promise.all(sessions.map(stopSession));
407
+ try {
408
+ await Promise.all(sessions.map(stopSession));
409
+ }
410
+ catch (error) {
411
+ if (!failureObserved)
412
+ throw error;
413
+ }
311
414
  }
312
415
  }
@@ -0,0 +1,12 @@
1
+ import type { LspBackend, Project } from '../../app/ports.js';
2
+ export interface LspServerOptions {
3
+ readonly command: string;
4
+ readonly args?: readonly string[];
5
+ readonly languageId: string;
6
+ readonly initializationProgressToken?: string | number;
7
+ readonly env?: NodeJS.ProcessEnv;
8
+ readonly requestTimeoutMs?: number;
9
+ }
10
+ export declare function createLspBackend(project: Project, signal: AbortSignal, options?: LspServerOptions): Promise<LspBackend>;
11
+ export declare function createRubyLspBackend(project: Project, signal: AbortSignal): Promise<LspBackend>;
12
+ export declare function createTypescriptLspBackend(project: Project, signal: AbortSignal): Promise<LspBackend>;