@alex-dbs/coderuler 1.4.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.
Files changed (38) hide show
  1. package/README.md +3 -1
  2. package/dist/adapter/cli/commands/check.js +21 -10
  3. package/dist/adapter/cli/program.js +2 -2
  4. package/dist/adapter/cli/reports.js +1 -1
  5. package/dist/adapter/cli/services.d.ts +4 -2
  6. package/dist/adapter/decision/jev.js +32 -15
  7. package/dist/adapter/decision/openai.d.ts +11 -0
  8. package/dist/adapter/decision/openai.js +160 -0
  9. package/dist/adapter/filesystem/rule-test-lsp.d.ts +6 -0
  10. package/dist/adapter/filesystem/rule-test-lsp.js +189 -0
  11. package/dist/adapter/filesystem/rule-test-worker.d.ts +5 -0
  12. package/dist/adapter/filesystem/rule-test-worker.js +48 -26
  13. package/dist/adapter/filesystem/rule-tests.d.ts +2 -2
  14. package/dist/adapter/filesystem/rule-tests.js +170 -29
  15. package/dist/adapter/git/changes.d.ts +2 -1
  16. package/dist/adapter/git/changes.js +369 -35
  17. package/dist/adapter/git/rules.d.ts +5 -0
  18. package/dist/adapter/git/rules.js +126 -0
  19. package/dist/adapter/github/pull-request.d.ts +2 -0
  20. package/dist/adapter/github/pull-request.js +9 -3
  21. package/dist/adapter/lsp/backend.d.ts +12 -0
  22. package/dist/adapter/lsp/backend.js +826 -0
  23. package/dist/app/concurrency.d.ts +1 -1
  24. package/dist/app/concurrency.js +12 -8
  25. package/dist/app/execute.js +84 -22
  26. package/dist/app/lsp.d.ts +6 -0
  27. package/dist/app/lsp.js +141 -0
  28. package/dist/app/ports.d.ts +13 -1
  29. package/dist/app/rule.d.ts +52 -10
  30. package/dist/app/rule.js +14 -0
  31. package/dist/app/validate.js +3 -2
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.js +1 -1
  34. package/dist/infra/runtime.js +27 -8
  35. package/dist/infra/settings.js +19 -8
  36. package/package.json +3 -1
  37. package/skills/coderuler-new-rule/SKILL.md +7 -3
  38. package/skills/coderuler-new-rule/reference.md +80 -13
@@ -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,35 +13,56 @@ function fixturePath(root, filepath) {
12
13
  }
13
14
  return target;
14
15
  }
15
- async function runCase({ root, ruleFile, files, ai, dependencies }) {
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
- const reports = await executeRules(rules, {
25
- project: createProject(root, { excludeFiles: [ruleFile] }),
26
- isTarget: () => true,
27
- concurrency: 1,
28
- resolveDecision: ai ? createDecisionResolver(process.env) : () => {
29
- throw new Error('Inline rule tests must not call the decision backend');
30
- },
31
- });
32
- const report = reports[0];
33
- if (reports.length !== 1 || report === undefined)
34
- throw new Error('Expected exactly one rule report');
35
- let aiScores;
36
- if (ai) {
37
- for (const evaluation of report.evaluations) {
38
- const score = evaluation.result.probability;
39
- if (score !== undefined)
40
- (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
+ }
41
64
  }
42
65
  }
43
- return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
44
66
  }
45
67
  const port = parentPort;
46
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): 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[]>;
@@ -1,10 +1,12 @@
1
- import { mkdtemp, realpath, rm } from 'node:fs/promises';
1
+ 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 { getRuleDependencies } from './rule-resolution.js';
7
+ import { createRuleTestLspHost } from './rule-test-lsp.js';
8
+ import { createProject } from './project.js';
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);
10
12
  const WORKER_ENTRY = new URL(`data:text/javascript,${encodeURIComponent(`import ${JSON.stringify(WORKER_MODULE.href)};`)}`);
@@ -24,6 +26,35 @@ function validateCase(value, label) {
24
26
  }
25
27
  return value;
26
28
  }
29
+ async function loadExternalTests(ruleFile, testFile) {
30
+ try {
31
+ await lstat(testFile);
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT')
35
+ return undefined;
36
+ throw error;
37
+ }
38
+ try {
39
+ const dependencies = getRuleDependencies(ruleFile);
40
+ if (dependencies !== undefined)
41
+ registerRuleDependencies(testFile, dependencies);
42
+ // The companion path comes from the selected rule filename.
43
+ return await import(pathToFileURL(testFile).href);
44
+ }
45
+ catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ throw new Error(`Rule test module ${testFile} failed to import: ${detail}`, { cause: error });
48
+ }
49
+ }
50
+ function addTestGroup(groups, values, sourceLabel, exportName, ai) {
51
+ if (values === undefined)
52
+ return;
53
+ if (!Array.isArray(values)) {
54
+ throw new Error(`${sourceLabel} ${exportName}: expected an array of test cases`);
55
+ }
56
+ groups.push({ values, label: `${sourceLabel} ${exportName}`, ai });
57
+ }
27
58
  class RuleTestWorkerSession {
28
59
  worker = new Worker(WORKER_ENTRY);
29
60
  nextId = 0;
@@ -32,6 +63,7 @@ class RuleTestWorkerSession {
32
63
  onFailure;
33
64
  terminating = false;
34
65
  termination;
66
+ activeLsp;
35
67
  onError = (error) => {
36
68
  if (this.terminating)
37
69
  return;
@@ -54,12 +86,41 @@ class RuleTestWorkerSession {
54
86
  if (this.failure !== undefined)
55
87
  throw this.failure;
56
88
  }
57
- 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) {
58
118
  this.onFailure = onFailure;
59
119
  this.assertHealthy();
60
120
  const id = this.nextId++;
61
121
  const result = Promise.withResolvers();
62
122
  let timer;
123
+ let activeLsp;
63
124
  const expire = () => {
64
125
  const remaining = deadline - performance.now();
65
126
  if (remaining <= 0)
@@ -83,29 +144,94 @@ class RuleTestWorkerSession {
83
144
  else
84
145
  result.reject(error);
85
146
  };
147
+ let operationFailed = false;
86
148
  this.worker.on('message', onMessage);
87
149
  try {
88
150
  expire();
89
151
  if (performance.now() < deadline) {
90
- 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
+ }
91
186
  }
92
187
  return await result.promise;
93
188
  }
189
+ catch (error) {
190
+ operationFailed = true;
191
+ throw error;
192
+ }
94
193
  finally {
95
194
  clearTimeout(timer);
96
195
  this.worker.off('message', onMessage);
97
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
+ }
98
206
  }
99
207
  }
100
208
  async stop() {
101
209
  if (this.termination === undefined) {
102
210
  this.terminating = true;
103
211
  this.rejectPending?.(new Error('Rule test worker stopped'));
104
- 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);
105
234
  }
106
- await this.termination;
107
- this.worker.off('error', this.onError);
108
- this.worker.off('exit', this.onExit);
109
235
  }
110
236
  }
111
237
  async function stopSession(session) {
@@ -114,7 +240,7 @@ async function stopSession(session) {
114
240
  if (session.current === current)
115
241
  session.current = undefined;
116
242
  }
117
- async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
243
+ async function runCase(entry, example, ai, timeoutMs, session, onFailure, includeDisabled, createLspBackend, createRubyLspBackend, createTypescriptLspBackend) {
118
244
  const started = performance.now();
119
245
  const deadline = started + timeoutMs;
120
246
  let failure;
@@ -141,8 +267,9 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
141
267
  const dependencies = getRuleDependencies(entry.ruleFile);
142
268
  const outcome = await active.run({
143
269
  root, ruleFile: entry.ruleFile, files: example.files, ai,
270
+ ...(includeDisabled ? { includeDisabled: true } : {}),
144
271
  ...(dependencies === undefined ? {} : { dependencies }),
145
- }, deadline, failCase);
272
+ }, deadline, failCase, createLspBackend, createRubyLspBackend, createTypescriptLspBackend);
146
273
  if (typeof outcome === 'string')
147
274
  actual = outcome;
148
275
  else {
@@ -180,7 +307,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
180
307
  throw cause;
181
308
  }
182
309
  }
183
- export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false) {
310
+ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false, includeDisabled = false, createLspBackend, createRubyLspBackend, createTypescriptLspBackend) {
184
311
  if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
185
312
  throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`);
186
313
  }
@@ -204,24 +331,28 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
204
331
  for (const rule of rules) {
205
332
  if (failureObserved)
206
333
  throw failure;
207
- if (!rule.rule.enabled)
334
+ if (!includeDisabled && !rule.rule.enabled)
208
335
  continue;
209
336
  // Discovery selects the rule module path at runtime.
210
- const { tests, aiTests } = await import(pathToFileURL(rule.ruleFile).href);
337
+ const ruleModule = await import(pathToFileURL(rule.ruleFile).href);
338
+ const testFile = rule.ruleFile.replace(/\.rule\.(ts|mts|js|mjs)$/, '.test.$1');
339
+ const externalModule = await loadExternalTests(rule.ruleFile, testFile);
340
+ const { tests, aiTests } = ruleModule;
211
341
  if (failureObserved)
212
342
  throw failure;
213
- if (!includeAI && aiTests !== undefined)
343
+ if (!includeAI && (aiTests !== undefined || externalModule?.aiTests !== undefined)) {
214
344
  hasExcludedAITests = true;
215
- const selectedAI = includeAI ? aiTests : undefined;
216
- const label = `Rule "${rule.name}"`;
217
- if (tests !== undefined && !Array.isArray(tests)) {
218
- throw new Error(`${label} tests: expected an array of test cases`);
219
345
  }
220
- if (selectedAI !== undefined && !Array.isArray(selectedAI)) {
221
- throw new Error(`${label} aiTests: expected an array of test cases`);
346
+ const label = `Rule "${rule.name}"`;
347
+ const externalLabel = `${label} test module ${testFile}`;
348
+ const groups = [];
349
+ addTestGroup(groups, tests, label, 'tests', false);
350
+ addTestGroup(groups, externalModule?.tests, externalLabel, 'tests', false);
351
+ if (includeAI) {
352
+ addTestGroup(groups, aiTests, label, 'aiTests', true);
353
+ addTestGroup(groups, externalModule?.aiTests, externalLabel, 'aiTests', true);
222
354
  }
223
- const regularCount = tests?.length ?? 0;
224
- const count = regularCount + (selectedAI?.length ?? 0);
355
+ const count = groups.reduce((total, group) => total + group.values.length, 0);
225
356
  const completed = new Array(count);
226
357
  let nextIndex = 0;
227
358
  let nextResult = 0;
@@ -231,11 +362,15 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
231
362
  if (index >= count)
232
363
  return;
233
364
  try {
234
- const ai = index >= regularCount;
235
- const caseIndex = ai ? index - regularCount : index;
236
- const value = ai ? selectedAI[caseIndex] : tests[caseIndex];
237
- const example = validateCase(value, `${label} ${ai ? 'aiTests' : 'tests'}[${caseIndex}]`);
238
- completed[index] = await runCase(rule, example, ai, timeoutMs, session, fail);
365
+ let groupIndex = 0;
366
+ let caseIndex = index;
367
+ while (caseIndex >= groups[groupIndex].values.length) {
368
+ caseIndex -= groups[groupIndex].values.length;
369
+ groupIndex += 1;
370
+ }
371
+ const group = groups[groupIndex];
372
+ const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`);
373
+ completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled, createLspBackend, createRubyLspBackend, createTypescriptLspBackend);
239
374
  if (failureObserved)
240
375
  return;
241
376
  while (completed[nextResult] !== undefined) {
@@ -269,6 +404,12 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
269
404
  }
270
405
  finally {
271
406
  await cancellation;
272
- 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
+ }
273
414
  }
274
415
  }
@@ -1 +1,2 @@
1
- export declare function changedFiles(root: string, base: string, env?: Readonly<Record<string, string | undefined>>): Promise<ReadonlySet<string>>;
1
+ import type { DiffChanges } from '../../app/ports.js';
2
+ export declare function collectChanges(root: string, base: string, env?: Readonly<Record<string, string | undefined>>): Promise<DiffChanges>;