@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.
@@ -1 +1 @@
1
- export declare function mapConcurrent<T, R>(items: readonly T[], concurrency: number, mapper: (item: T, index: number, reportFailure: (error: unknown) => void) => Promise<R>): Promise<R[]>;
1
+ export declare function mapConcurrent<T, R>(items: readonly T[], concurrency: number, mapper: (item: T, index: number, reportFailure: (error: unknown) => void) => Promise<R>, scheduler?: (work: Promise<R>) => Promise<R>): Promise<R[]>;
@@ -6,7 +6,7 @@ function requirePositiveSafeInteger(concurrency) {
6
6
  throw invalidConcurrency(String(concurrency));
7
7
  }
8
8
  }
9
- export async function mapConcurrent(items, concurrency, mapper) {
9
+ export async function mapConcurrent(items, concurrency, mapper, scheduler = work => work) {
10
10
  requirePositiveSafeInteger(concurrency);
11
11
  const results = new Array(items.length);
12
12
  if (items.length === 0)
@@ -14,12 +14,12 @@ export async function mapConcurrent(items, concurrency, mapper) {
14
14
  const workerCount = Math.min(items.length, concurrency);
15
15
  let nextIndex = 0;
16
16
  let failureObserved = false;
17
- let failure;
18
- // A mapper may need to drain started work after it has already failed.
17
+ const { promise: failureSignal, resolve: signalFailure } = Promise.withResolvers();
18
+ // A mapper may report failure before its own cleanup finishes.
19
19
  const reportFailure = (error) => {
20
20
  if (!failureObserved) {
21
21
  failureObserved = true;
22
- failure = error;
22
+ signalFailure({ error });
23
23
  }
24
24
  };
25
25
  const runWorker = async () => {
@@ -29,7 +29,8 @@ export async function mapConcurrent(items, concurrency, mapper) {
29
29
  if (index >= items.length)
30
30
  return;
31
31
  try {
32
- results[index] = await mapper(items[index], index, reportFailure);
32
+ const work = scheduler(Promise.resolve().then(() => mapper(items[index], index, reportFailure)));
33
+ results[index] = await work;
33
34
  }
34
35
  catch (error) {
35
36
  reportFailure(error);
@@ -37,8 +38,11 @@ export async function mapConcurrent(items, concurrency, mapper) {
37
38
  }
38
39
  };
39
40
  const workers = Array.from({ length: workerCount }, () => runWorker());
40
- await Promise.all(workers);
41
- if (failureObserved)
42
- throw failure;
41
+ const outcome = await Promise.race([
42
+ Promise.all(workers).then(() => ({ error: undefined, failed: false })),
43
+ failureSignal.then(({ error }) => ({ error, failed: true })),
44
+ ]);
45
+ if (outcome.failed)
46
+ throw outcome.error;
43
47
  return results;
44
48
  }
@@ -1,9 +1,15 @@
1
1
  import { matchesGlob } from 'node:path';
2
+ import { createRunLsp } from './lsp.js';
2
3
  import { mapConcurrent } from './concurrency.js';
3
4
  import { evaluateAi } from './evaluators.js';
4
5
  import { wholeFileScope } from './selection.js';
5
6
  import { addUsage } from './usage.js';
6
7
  import { validateCheckResult, validatePredicateResult, validateScopeMatch, validateScopeSubjects, } from './validate.js';
8
+ function trackWork(pending, work) {
9
+ pending.add(work);
10
+ void work.then(() => { pending.delete(work); }, () => { pending.delete(work); });
11
+ return work;
12
+ }
7
13
  function isEligibleRelativePath(filepath) {
8
14
  if (filepath === '' || filepath.startsWith('/'))
9
15
  return false;
@@ -26,6 +32,55 @@ function folderPath(filepath, level) {
26
32
  return segments.slice(0, keep).join('/');
27
33
  }
28
34
  export async function executeRules(rules, context) {
35
+ const goLsp = createRunLsp(context.project, context.createLspBackend);
36
+ const rubyLsp = createRunLsp(context.project, context.createRubyLspBackend);
37
+ const typescriptLsp = createRunLsp(context.project, context.createTypescriptLspBackend);
38
+ const closeLspSessions = async () => {
39
+ const results = await Promise.allSettled([goLsp.close(), rubyLsp.close(), typescriptLsp.close()]);
40
+ const goResult = results[0];
41
+ if (goResult.status === 'rejected')
42
+ throw goResult.reason;
43
+ const rubyResult = results[1];
44
+ if (rubyResult.status === 'rejected')
45
+ throw rubyResult.reason;
46
+ const typescriptResult = results[2];
47
+ if (typescriptResult.status === 'rejected')
48
+ throw typescriptResult.reason;
49
+ };
50
+ const abortLsp = () => { void closeLspSessions().catch(() => undefined); };
51
+ const startedWork = new Set();
52
+ const scheduleWork = (work) => {
53
+ startedWork.add(work);
54
+ void work.then(() => { startedWork.delete(work); }, () => { startedWork.delete(work); });
55
+ return work;
56
+ };
57
+ const drainStartedWork = async () => {
58
+ while (startedWork.size > 0)
59
+ await Promise.allSettled([...startedWork]);
60
+ };
61
+ let failed = false;
62
+ try {
63
+ const reports = await executeRun(rules, context, goLsp.tools, rubyLsp.tools, typescriptLsp.tools, scheduleWork, abortLsp);
64
+ await drainStartedWork();
65
+ return reports;
66
+ }
67
+ catch (error) {
68
+ failed = true;
69
+ abortLsp();
70
+ await drainStartedWork();
71
+ throw error;
72
+ }
73
+ finally {
74
+ try {
75
+ await closeLspSessions();
76
+ }
77
+ catch (error) {
78
+ if (!failed)
79
+ throw error;
80
+ }
81
+ }
82
+ }
83
+ async function executeRun(rules, context, goLsp, rubyLsp, typescriptLsp, scheduleWork, abortLsp) {
29
84
  let completedSelections = 0;
30
85
  let completedEvaluations = 0;
31
86
  const emit = (event) => {
@@ -47,6 +102,9 @@ export async function executeRules(rules, context) {
47
102
  };
48
103
  const projectContextFor = (name, evaluationId) => ({
49
104
  project: context.project,
105
+ goLsp,
106
+ rubyLsp,
107
+ typescriptLsp,
50
108
  ...(changedFiles === undefined ? {} : { changedFiles }),
51
109
  ...(changedLines === undefined ? {} : { changedLines }),
52
110
  trace: traceFor(name, evaluationId),
@@ -83,7 +141,7 @@ export async function executeRules(rules, context) {
83
141
  }
84
142
  }
85
143
  };
86
- const selectFromRule = async (name, selection, evaluationId, changedOnly = false) => {
144
+ const selectFromRule = async (name, selection, evaluationId, changedOnly = false, pendingWork) => {
87
145
  const exclude = selection.exclude ?? [];
88
146
  const candidates = await context.project.glob(selection.files);
89
147
  const chosen = [];
@@ -105,6 +163,9 @@ export async function executeRules(rules, context) {
105
163
  chosen.push(filepath);
106
164
  }
107
165
  const scope = selection.scope;
166
+ const scheduler = pendingWork === undefined
167
+ ? scheduleWork
168
+ : (work) => trackWork(pendingWork, work);
108
169
  const collected = await mapConcurrent(chosen, context.concurrency, async (filepath) => {
109
170
  const source = await context.project.readText(filepath);
110
171
  const matches = scope === undefined
@@ -122,7 +183,7 @@ export async function executeRules(rules, context) {
122
183
  subjects.push({ kind: 'scope', filepath, ...valid });
123
184
  }
124
185
  return subjects;
125
- });
186
+ }, scheduler);
126
187
  return collected.flat();
127
188
  };
128
189
  const selectionContextFor = (name, changedOnly) => ({
@@ -142,13 +203,14 @@ export async function executeRules(rules, context) {
142
203
  const { name, rule } = plan;
143
204
  emit({ type: 'evaluation', evaluationId, name, subject });
144
205
  let trackedUsage;
145
- let pendingAi;
206
+ let pendingWork;
146
207
  let accountingFailure;
147
208
  const retainAccountingFailure = (error) => {
148
209
  if (accountingFailure !== undefined)
149
210
  return;
150
211
  const failure = error instanceof Error ? error : new Error(String(error));
151
212
  accountingFailure = { error: failure };
213
+ abortLsp();
152
214
  reportFailure(failure);
153
215
  };
154
216
  const throwIfAccountingFailed = () => {
@@ -183,14 +245,13 @@ export async function executeRules(rules, context) {
183
245
  const checkContext = {
184
246
  ...projectContextFor(name, evaluationId),
185
247
  ruleName: name,
186
- select: selection => selectFromRule(name, selection, evaluationId),
248
+ // Let the check catch a nested selection rejection. If it escapes, the
249
+ // enclosing check handler records it before aborting sibling work.
250
+ select: selection => selectFromRule(name, selection, evaluationId, false, pendingWork ??= new Set()),
187
251
  ai: options => {
188
252
  const call = evaluateAi(subject, options, { ...projectContextFor(name, evaluationId), ruleName: name }, runtimeOf(plan), observer);
189
- const pending = pendingAi ??= new Set();
190
- pending.add(call);
191
- // Observe both outcomes immediately; the check still receives the original promise.
192
- void call.then(() => { pending.delete(call); }, () => { pending.delete(call); });
193
- return call;
253
+ const pending = pendingWork ??= new Set();
254
+ return trackWork(pending, call);
194
255
  },
195
256
  };
196
257
  let result;
@@ -223,12 +284,13 @@ export async function executeRules(rules, context) {
223
284
  const cause = accountingFailure === undefined ? error : accountingFailure.error;
224
285
  const failure = cause instanceof Error ? cause : new Error(String(cause));
225
286
  reportFailure(failure);
287
+ abortLsp();
226
288
  throw failure;
227
289
  }
228
290
  finally {
229
- // Promise.all may reject while sibling calls are still producing usage events.
230
- while (pendingAi !== undefined && pendingAi.size > 0) {
231
- await Promise.allSettled(pendingAi);
291
+ // The check can settle while sibling AI calls or selector workers still produce usage.
292
+ while (pendingWork !== undefined && pendingWork.size > 0) {
293
+ await Promise.allSettled(pendingWork);
232
294
  }
233
295
  }
234
296
  // A custom check may catch context.ai failures, but cannot discard accounting failures.
@@ -372,7 +434,7 @@ export async function executeRules(rules, context) {
372
434
  }
373
435
  }
374
436
  context.emit?.({ type: 'progress', phase: 'evaluation', completed: 0, total: tasks.length });
375
- const results = await mapConcurrent(tasks, context.concurrency, (task, _index, reportFailure) => evaluateSubject(task.plan, task.subject, task.evaluationId, reportFailure));
437
+ const results = await mapConcurrent(tasks, context.concurrency, (task, _index, reportFailure) => evaluateSubject(task.plan, task.subject, task.evaluationId, reportFailure), scheduleWork);
376
438
  for (let index = 0; index < tasks.length; index += 1) {
377
439
  const task = tasks[index];
378
440
  task.plan.evaluations.push({ subject: task.subject, result: results[index] });
@@ -0,0 +1,6 @@
1
+ import type { LspTools } from './rule.js';
2
+ import type { LspBackendFactory, Project } from './ports.js';
3
+ export declare function createRunLsp(project: Project, createBackend?: LspBackendFactory): {
4
+ readonly tools: LspTools;
5
+ close(): Promise<void>;
6
+ };
@@ -0,0 +1,141 @@
1
+ export function createRunLsp(project, createBackend) {
2
+ const controller = new AbortController();
3
+ const pending = new Set();
4
+ let startup;
5
+ let backend;
6
+ let backendClose;
7
+ let closeFailure;
8
+ let hasCloseFailure = false;
9
+ let closePromise;
10
+ let closed = false;
11
+ const closedError = () => new Error('LSP session is closed');
12
+ const closeBackend = (owned) => {
13
+ if (backendClose === undefined) {
14
+ backendClose = Promise.resolve().then(() => owned.close());
15
+ void backendClose.catch(error => {
16
+ closeFailure = error;
17
+ hasCloseFailure = true;
18
+ });
19
+ }
20
+ return backendClose;
21
+ };
22
+ const getBackend = () => {
23
+ if (closed)
24
+ return Promise.reject(closedError());
25
+ if (startup !== undefined)
26
+ return startup;
27
+ if (createBackend === undefined) {
28
+ startup = Promise.reject(new Error('LSP is not configured for this run'));
29
+ void startup.catch(() => undefined);
30
+ return startup;
31
+ }
32
+ startup = Promise.resolve().then(async () => {
33
+ if (controller.signal.aborted)
34
+ throw closedError();
35
+ const created = await createBackend(project, controller.signal);
36
+ backend = created;
37
+ if (closed) {
38
+ await closeBackend(created);
39
+ throw closedError();
40
+ }
41
+ return created;
42
+ });
43
+ void startup.catch(() => undefined);
44
+ return startup;
45
+ };
46
+ const observe = (promise) => {
47
+ pending.add(promise);
48
+ void promise.then(() => pending.delete(promise), () => pending.delete(promise));
49
+ return promise;
50
+ };
51
+ const rejectCall = (error) => observe(Promise.reject(error));
52
+ const invoke = (operation) => observe(Promise.resolve().then(async () => {
53
+ if (closed)
54
+ throw closedError();
55
+ const active = await getBackend();
56
+ if (closed || controller.signal.aborted)
57
+ throw closedError();
58
+ return operation(active);
59
+ }));
60
+ const validateTarget = (target) => {
61
+ if (!Number.isSafeInteger(target.line) || target.line < 1) {
62
+ throw new Error('LSP line must be a positive safe integer');
63
+ }
64
+ if (target.column !== undefined && (!Number.isSafeInteger(target.column) || target.column < 1)) {
65
+ throw new Error('LSP column must be a positive safe integer');
66
+ }
67
+ };
68
+ const withTarget = (target, operation) => {
69
+ if (closed)
70
+ return rejectCall(closedError());
71
+ try {
72
+ validateTarget(target);
73
+ }
74
+ catch (error) {
75
+ return rejectCall(error);
76
+ }
77
+ return invoke(operation);
78
+ };
79
+ const withGraph = (target, options, operation) => {
80
+ if (closed)
81
+ return rejectCall(closedError());
82
+ if (!Number.isSafeInteger(options.depth) || options.depth < 0) {
83
+ return rejectCall(new Error('LSP call depth must be a nonnegative safe integer'));
84
+ }
85
+ try {
86
+ validateTarget(target);
87
+ }
88
+ catch (error) {
89
+ return rejectCall(error);
90
+ }
91
+ if (options.depth === 0)
92
+ return Promise.resolve([]);
93
+ return invoke(operation);
94
+ };
95
+ const tools = {
96
+ definitions: (target, options) => withTarget(target, active => active.definitions(target, options)),
97
+ declarations: (target, options) => withTarget(target, active => active.declarations(target, options)),
98
+ typeDefinitions: (target, options) => withTarget(target, active => active.typeDefinitions(target, options)),
99
+ implementations: (target, options) => withTarget(target, active => active.implementations(target, options)),
100
+ references: (target, options) => withTarget(target, active => active.references(target, options)),
101
+ hover: (target, options) => withTarget(target, active => active.hover(target, options)),
102
+ documentSymbols: (filepath, options) => invoke(active => active.documentSymbols(filepath, options)),
103
+ workspaceSymbols: (query, options) => {
104
+ if (closed)
105
+ return rejectCall(closedError());
106
+ return query.length === 0 ? Promise.resolve([]) : invoke(active => active.workspaceSymbols(query, options));
107
+ },
108
+ callers: (target, options) => withGraph(target, options, active => active.callers(target, options)),
109
+ callees: (target, options) => withGraph(target, options, active => active.callees(target, options)),
110
+ };
111
+ const close = () => {
112
+ if (closePromise !== undefined)
113
+ return closePromise;
114
+ closed = true;
115
+ controller.abort();
116
+ closePromise = Promise.resolve().then(async () => {
117
+ if (startup !== undefined) {
118
+ try {
119
+ backend = await startup;
120
+ }
121
+ catch {
122
+ // The request reports startup failures. There is no backend to close.
123
+ }
124
+ }
125
+ if (backend !== undefined) {
126
+ try {
127
+ await closeBackend(backend);
128
+ }
129
+ catch {
130
+ // Preserve the close failure after pending calls settle.
131
+ }
132
+ }
133
+ await Promise.allSettled([...pending]);
134
+ if (hasCloseFailure)
135
+ throw closeFailure;
136
+ });
137
+ void closePromise.catch(() => undefined);
138
+ return closePromise;
139
+ };
140
+ return { tools, close };
141
+ }
@@ -1,4 +1,4 @@
1
- import type { AiUsage, CheckResult, GroupSubject, JsonValue, LineRange, Rule, RuleSettings, ScopeSubject, Subject, TokenPricing, TokenUsage } from './rule.js';
1
+ import type { AiUsage, Awaitable, CheckResult, GroupSubject, JsonValue, LineRange, LspTools, Rule, RuleSettings, ScopeSubject, Subject, TokenPricing, TokenUsage } from './rule.js';
2
2
  export interface DiffChanges {
3
3
  readonly changedFiles: ReadonlySet<string>;
4
4
  readonly changedLines: ReadonlyMap<string, readonly LineRange[]>;
@@ -15,6 +15,10 @@ export interface Project {
15
15
  readYaml(path: string): Promise<readonly JsonValue[]>;
16
16
  readCsv(path: string, options?: CsvOptions): Promise<readonly CsvRow[]>;
17
17
  }
18
+ export interface LspBackend extends LspTools {
19
+ close(): Promise<void>;
20
+ }
21
+ export type LspBackendFactory = (project: Project, signal: AbortSignal) => Awaitable<LspBackend>;
18
22
  export interface CsvOptions {
19
23
  readonly headers?: boolean;
20
24
  readonly delimiter?: string;
@@ -75,6 +79,9 @@ export interface DecisionRuntime {
75
79
  export interface RunContext {
76
80
  readonly project: Project;
77
81
  readonly isTarget: (filepath: string) => boolean;
82
+ readonly createLspBackend?: LspBackendFactory;
83
+ readonly createRubyLspBackend?: LspBackendFactory;
84
+ readonly createTypescriptLspBackend?: LspBackendFactory;
78
85
  readonly changedFiles?: ReadonlySet<string>;
79
86
  readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
80
87
  readonly concurrency: number;
@@ -1,4 +1,6 @@
1
+ import type { DocumentSymbol, Hover, Location, SymbolInformation, WorkspaceSymbol } from 'vscode-languageserver-protocol';
1
2
  import type { Project } from './ports.js';
3
+ export type { DocumentSymbol, Hover, Location, SymbolInformation, WorkspaceSymbol, } from 'vscode-languageserver-protocol';
2
4
  export type Awaitable<T> = T | Promise<T>;
3
5
  export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
4
6
  readonly [key: string]: JsonValue;
@@ -15,6 +17,39 @@ export interface LineRange {
15
17
  readonly start: number;
16
18
  readonly end: number;
17
19
  }
20
+ export interface LspTarget {
21
+ readonly filepath: string;
22
+ readonly line: number;
23
+ readonly column?: number;
24
+ }
25
+ export interface LspOptions {
26
+ readonly includeDependencies?: boolean;
27
+ }
28
+ export interface LspReferenceOptions extends LspOptions {
29
+ readonly includeDeclaration?: boolean;
30
+ }
31
+ export interface LspCallOptions extends LspOptions {
32
+ readonly depth: number;
33
+ }
34
+ export interface LspCall {
35
+ readonly name: string;
36
+ readonly filepath: string;
37
+ readonly source: string;
38
+ readonly lines: LineRange;
39
+ readonly depth: number;
40
+ }
41
+ export interface LspTools {
42
+ definitions(target: LspTarget, options?: LspOptions): Promise<readonly Location[]>;
43
+ declarations(target: LspTarget, options?: LspOptions): Promise<readonly Location[]>;
44
+ typeDefinitions(target: LspTarget, options?: LspOptions): Promise<readonly Location[]>;
45
+ implementations(target: LspTarget, options?: LspOptions): Promise<readonly Location[]>;
46
+ references(target: LspTarget, options?: LspReferenceOptions): Promise<readonly Location[]>;
47
+ hover(target: LspTarget, options?: LspOptions): Promise<Hover | null>;
48
+ documentSymbols(filepath: string, options?: LspOptions): Promise<readonly DocumentSymbol[] | readonly SymbolInformation[]>;
49
+ workspaceSymbols(query: string, options?: LspOptions): Promise<readonly (SymbolInformation | WorkspaceSymbol)[]>;
50
+ callers(target: LspTarget, options: LspCallOptions): Promise<readonly LspCall[]>;
51
+ callees(target: LspTarget, options: LspCallOptions): Promise<readonly LspCall[]>;
52
+ }
18
53
  export interface ScopeMatch<T = JsonValue> {
19
54
  readonly id: string;
20
55
  readonly source: string;
@@ -96,6 +131,9 @@ export type AiOptions<T = JsonValue> = {
96
131
  export type Evaluator<T = JsonValue> = (subject: Subject<T>, context: CheckContext<T>) => Awaitable<CheckResult>;
97
132
  export interface ProjectContext {
98
133
  readonly project: Project;
134
+ readonly goLsp: LspTools;
135
+ readonly rubyLsp: LspTools;
136
+ readonly typescriptLsp: LspTools;
99
137
  readonly changedFiles?: ReadonlySet<string>;
100
138
  readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
101
139
  trace(message: string, details?: JsonValue): void;
@@ -112,6 +150,8 @@ export interface CheckContext<T = JsonValue> extends SelectionContext {
112
150
  readonly ruleName: string;
113
151
  ai(options: AiOptions<T>): Promise<CheckResult>;
114
152
  }
153
+ export declare function isDiffMode(context: ProjectContext): boolean;
154
+ export declare function isInDiff(subject: ScopeSubject<unknown>, context: ProjectContext): boolean;
115
155
  export declare const RULE_BRAND: symbol;
116
156
  export declare abstract class Rule<T = JsonValue> {
117
157
  enabled: boolean;
package/dist/app/rule.js CHANGED
@@ -1,3 +1,17 @@
1
+ export function isDiffMode(context) {
2
+ return context.changedFiles !== undefined || context.changedLines !== undefined;
3
+ }
4
+ export function isInDiff(subject, context) {
5
+ const lines = subject.lines;
6
+ const ranges = context.changedLines?.get(subject.filepath);
7
+ if (lines === undefined || ranges === undefined)
8
+ return false;
9
+ for (const range of ranges) {
10
+ if (range.start <= lines.end && lines.start <= range.end)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
1
15
  export const RULE_BRAND = Symbol.for('coderuler.Rule');
2
16
  export class Rule {
3
17
  enabled = true;
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- export { Rule } from './app/rule.js';
1
+ export { Rule, isDiffMode, isInDiff } from './app/rule.js';
2
2
  export { test } from './app/rule-tests.js';
3
3
  export type { RuleTestCase } from './app/rule-tests.js';
4
- export type { AiExamples, AiOptions, AiUsage, Awaitable, CheckContext, CheckResult, Evaluator, Grouping, GroupSubject, JsonValue, PredicateResult, LineRange, ProjectContext, RuleSettings, ScopeMatch, ScopeSelector, ScopeSubject, Selection, SelectionContext, SourceFile, Subject, TokenPricing, TokenUsage, ViolationLocation, } from './app/rule.js';
4
+ export type { AiExamples, AiOptions, AiUsage, Awaitable, CheckContext, CheckResult, DocumentSymbol, Evaluator, Grouping, GroupSubject, Hover, JsonValue, LineRange, Location, LspCall, LspCallOptions, LspOptions, LspReferenceOptions, LspTarget, LspTools, PredicateResult, ProjectContext, RuleSettings, ScopeMatch, ScopeSelector, ScopeSubject, Selection, SelectionContext, SourceFile, Subject, SymbolInformation, TokenPricing, TokenUsage, ViolationLocation, WorkspaceSymbol, } from './app/rule.js';
5
5
  export { ai, pass, regex, violation } from './app/evaluators.js';
6
6
  export type { RegexOptions } from './app/evaluators.js';
7
- export type { CsvOptions, DiffChanges, CsvRow, DecisionBackend, DecisionExchange, DecisionInstructions, DecisionObserver, DecisionOutcome, DecisionRequest, DecisionRuntime, LoadedRule, Project, RuleEvaluation, RuleLoader, RuleReport, RuleSelection, RunContext, RunEvent, } from './app/ports.js';
7
+ export type { CsvOptions, DiffChanges, CsvRow, DecisionBackend, DecisionExchange, DecisionInstructions, DecisionObserver, DecisionOutcome, DecisionRequest, DecisionRuntime, LoadedRule, LspBackend, LspBackendFactory, Project, RuleEvaluation, RuleLoader, RuleReport, RuleSelection, RunContext, RunEvent, } from './app/ports.js';
8
8
  import { astGrep } from './adapter/scope/astgrep.js';
9
9
  import { csv } from './adapter/scope/csv.js';
10
10
  import { file } from './adapter/scope/file.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { Rule } from './app/rule.js';
1
+ export { Rule, isDiffMode, isInDiff } from './app/rule.js';
2
2
  export { test } from './app/rule-tests.js';
3
3
  export { ai, pass, regex, violation } from './app/evaluators.js';
4
4
  import { astGrep } from './adapter/scope/astgrep.js';
@@ -1,6 +1,7 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
+ import { createLspBackend, createRubyLspBackend, createTypescriptLspBackend } from '../adapter/lsp/backend.js';
4
5
  import { runProgram } from '../adapter/cli/program.js';
5
6
  import { terminalCapabilities } from '../adapter/cli/progress.js';
6
7
  import { createRedactor, escapeDiagnostic } from '../adapter/cli/text.js';
@@ -67,7 +68,12 @@ export function createServices(cwd, env) {
67
68
  ...ruleServices,
68
69
  createProject: project.createProject,
69
70
  resolveTarget: project.resolveTarget,
70
- runRules: execution.executeRules,
71
+ runRules: (rules, runContext) => execution.executeRules(rules, {
72
+ ...runContext,
73
+ createLspBackend: runContext.createLspBackend ?? createLspBackend,
74
+ createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend,
75
+ createTypescriptLspBackend: runContext.createTypescriptLspBackend ?? createTypescriptLspBackend,
76
+ }),
71
77
  decisionResolver: (thresholdOverride, pricingOverrides) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides),
72
78
  async collectChanges(root, base) {
73
79
  const { collectChanges } = await import('../adapter/git/changes.js');
@@ -98,7 +104,12 @@ export function createServices(cwd, env) {
98
104
  ...ruleServices,
99
105
  createProject: project.createProject,
100
106
  resolveTarget: project.resolveTarget,
101
- runRules: execution.executeRules,
107
+ runRules: (rules, runContext) => execution.executeRules(rules, {
108
+ ...runContext,
109
+ createLspBackend: runContext.createLspBackend ?? createLspBackend,
110
+ createRubyLspBackend: runContext.createRubyLspBackend ?? createRubyLspBackend,
111
+ createTypescriptLspBackend: runContext.createTypescriptLspBackend ?? createTypescriptLspBackend,
112
+ }),
102
113
  decisionResolver: (thresholdOverride, pricingOverrides) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides),
103
114
  };
104
115
  },
@@ -107,7 +118,7 @@ export function createServices(cwd, env) {
107
118
  const { runRuleTests } = await import('../adapter/filesystem/rule-tests.js');
108
119
  return {
109
120
  ...ruleServices,
110
- testRules: (rules, onResult, timeoutMs, concurrency, includeAI) => runRuleTests(rules, onResult, timeoutMs, concurrency, includeAI),
121
+ testRules: (rules, onResult, timeoutMs, concurrency, includeAI) => runRuleTests(rules, onResult, timeoutMs, concurrency, includeAI, false, createLspBackend, createRubyLspBackend, createTypescriptLspBackend),
111
122
  };
112
123
  },
113
124
  async update(_context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alex-dbs/coderuler",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "keywords": [
6
6
  "ai",
@@ -95,6 +95,7 @@
95
95
  "skills": "1.7.2",
96
96
  "ssri": "14.0.0",
97
97
  "string-width": "8.3.0",
98
+ "vscode-languageserver-protocol": "3.18.5",
98
99
  "yaml": "^2.9.1"
99
100
  },
100
101
  "devDependencies": {
@@ -334,9 +334,62 @@ Results validate; supplied messages survive reporting. Violations without a mess
334
334
 
335
335
  `changedFiles` contains paths that changed in the project. `changedLines` maps changed paths to added or replaced lines. These values are absent outside `--diff` and `--pr` runs. In those runs, an empty set means no changed files. An empty map means no exact line ranges are available. A missing entry for a path means line data is unavailable, for example for binary or conversion-attributed files. An entry with `[]` means the file changed but has no added lines.
336
336
 
337
+ `isDiffMode(context)` returns `true` when either `changedFiles` or `changedLines` is defined, even if empty; it returns `false` when both are absent. `isInDiff(subject, context)` returns `true` when the subject's line span overlaps a known changed range; a missing span or range returns `false`.
338
+
339
+ Use both helpers to keep full scans outside diff mode:
340
+
341
+ ```ts
342
+ import {
343
+ Rule,
344
+ isDiffMode,
345
+ isInDiff,
346
+ type ProjectContext,
347
+ type ScopeSubject,
348
+ } from '@alex-dbs/coderuler';
349
+
350
+ export default class Local extends Rule {
351
+ filter = (subject: ScopeSubject, context: ProjectContext) =>
352
+ !isDiffMode(context) || isInDiff(subject, context);
353
+ }
354
+ ```
337
355
 
338
356
  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.
339
357
 
358
+ `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.
359
+
360
+ 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`.
361
+
362
+ 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.
363
+
364
+ Call graph helpers from an async custom check:
365
+
366
+ ```ts
367
+ import { Rule, pass, scopes, violation } from '@alex-dbs/coderuler';
368
+ import type { Evaluator } from '@alex-dbs/coderuler';
369
+
370
+ export default class CallGraphCheck extends Rule {
371
+ files = ['**/*.go'];
372
+ scope = scopes.astGrep({
373
+ language: 'go',
374
+ rule: { any: [{ kind: 'function_declaration' }, { kind: 'method_declaration' }] },
375
+ });
376
+
377
+ check: Evaluator = async (subject, context) => {
378
+ if (subject.kind !== 'scope' || subject.lines === undefined) return pass();
379
+ const target = { filepath: subject.filepath, line: subject.lines.start };
380
+ const [callers, callees] = await Promise.all([
381
+ context.goLsp.callers(target, { depth: 2 }),
382
+ context.goLsp.callees(target, { depth: 2, includeDependencies: true }),
383
+ ]);
384
+ return callers.length + callees.length === 0
385
+ ? pass()
386
+ : violation('Review callers and callees');
387
+ };
388
+ }
389
+ ```
390
+
391
+ The example assumes `pass` and `violation` are imported from the SDK.
392
+
340
393
  ## Regex: matching means passing
341
394
 
342
395
  `regex<T = JsonValue>(options: RegexOptions<T>): Evaluator<T>` takes `{ readonly pattern: RegExp; readonly violationMessage?: string; readonly input?: (subject: ScopeSubject<T>, context: ProjectContext) => Awaitable<string> }`.