@alex-dbs/coderuler 1.3.1 → 1.5.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,126 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, realpath, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { isolatedGitEnvironment } from './environment.js';
6
+ const invalidRepositoryUrl = 'Invalid rule repository URL; use HTTPS or SSH with Git credentials.';
7
+ function parseRuleRepositorySelector(selector) {
8
+ const schemeMatch = /^([a-z][a-z\d+.-]*):\/\//i.exec(selector);
9
+ const schemePrefix = /^([a-z][a-z\d+.-]*):/i.exec(selector);
10
+ const windowsDrivePath = /^[a-z]:[\\/]/i.test(selector);
11
+ const malformedUrl = schemeMatch === null
12
+ && schemePrefix !== null
13
+ && !windowsDrivePath
14
+ && (/^(?:https?|ssh|file|ftp|git(?:\+(?:https?|ssh))?):/i.test(selector)
15
+ || /^[\\/]/.test(selector.slice(schemePrefix[0].length))
16
+ || selector.slice(schemePrefix[0].length).includes('@'));
17
+ const looksLikeUrl = schemeMatch !== null || malformedUrl;
18
+ const looksLikeScp = /^[^/@:\s]*@[^/:\s]*:/.test(selector);
19
+ if (!looksLikeUrl && !looksLikeScp)
20
+ return undefined;
21
+ const separator = selector.indexOf('#');
22
+ const transport = separator === -1 ? selector : selector.slice(0, separator);
23
+ const revision = separator === -1 ? undefined : selector.slice(separator + 1);
24
+ if (revision === '')
25
+ throw new Error('Rule repository revision must not be empty.');
26
+ if (looksLikeUrl) {
27
+ if (schemeMatch === null)
28
+ throw new Error(invalidRepositoryUrl);
29
+ let url;
30
+ try {
31
+ url = new URL(transport);
32
+ }
33
+ catch {
34
+ throw new Error(invalidRepositoryUrl);
35
+ }
36
+ if ((url.protocol !== 'https:' && url.protocol !== 'ssh:')
37
+ || url.hostname === ''
38
+ || url.search !== ''
39
+ || transport.includes('?')
40
+ || (url.protocol === 'https:' && (url.username !== '' || url.password !== ''))
41
+ || (url.protocol === 'ssh:' && hasSshPassword(transport, url))) {
42
+ throw new Error(invalidRepositoryUrl);
43
+ }
44
+ }
45
+ else {
46
+ const match = /^([^/@:\s]+)@([^/:\s]+):(.+)$/.exec(transport);
47
+ if (match === null)
48
+ throw new Error(invalidRepositoryUrl);
49
+ }
50
+ return { transport, revision };
51
+ }
52
+ function hasSshPassword(transport, url) {
53
+ if (url.password !== '')
54
+ return true;
55
+ const authorityStart = transport.indexOf('://') + 3;
56
+ const authorityEnd = transport.slice(authorityStart).search(/[/?#]/);
57
+ const authority = authorityEnd === -1
58
+ ? transport.slice(authorityStart)
59
+ : transport.slice(authorityStart, authorityStart + authorityEnd);
60
+ const userInfoEnd = authority.lastIndexOf('@');
61
+ return userInfoEnd !== -1 && authority.slice(0, userInfoEnd).includes(':');
62
+ }
63
+ export function createRuleRepositoryCheckout(env) {
64
+ const isolatedEnv = isolatedGitEnvironment(env);
65
+ const runGit = (cwd, args, allowedExitCodes = [0]) => {
66
+ const { promise, resolve, reject } = Promise.withResolvers();
67
+ const child = spawn('git', [...args], {
68
+ cwd,
69
+ env: isolatedEnv,
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ let stdout = '';
73
+ let stderr = '';
74
+ child.stdout.setEncoding('utf8');
75
+ child.stderr.setEncoding('utf8');
76
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
77
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
78
+ child.on('error', error => reject(new Error(`Cannot run Git: ${error.message}`, {
79
+ cause: error,
80
+ })));
81
+ child.on('close', (code, signal) => {
82
+ if (code !== null && allowedExitCodes.includes(code))
83
+ resolve({ stdout, code });
84
+ else
85
+ reject(new Error(`Git ${args[0]} failed: ${stderr.trim() || signal || `exit ${code}`}`));
86
+ });
87
+ return promise;
88
+ };
89
+ return async (selector) => {
90
+ const parsed = parseRuleRepositorySelector(selector);
91
+ if (parsed === undefined)
92
+ return undefined;
93
+ const workspace = await mkdtemp(join(await realpath(tmpdir()), 'coderuler-rules-'));
94
+ const root = join(workspace, 'repository');
95
+ const cleanup = () => rm(workspace, { recursive: true, force: true });
96
+ try {
97
+ await runGit(workspace, ['init', root]);
98
+ const worktree = await runGit(root, ['config', '--null', '--get-all', 'core.worktree'], [0, 1]);
99
+ if (worktree.code === 0) {
100
+ throw new Error('Rule repository checkout requires Git core.worktree to be unset; remove the inherited repository-location override.');
101
+ }
102
+ if (parsed.revision !== undefined) {
103
+ if (parsed.revision.startsWith('+')
104
+ && !parsed.revision.startsWith('refs/heads/+')) {
105
+ throw new Error('Invalid rule repository revision.');
106
+ }
107
+ const result = await runGit(root, [
108
+ 'check-ref-format',
109
+ `refs/coderuler/${parsed.revision}`,
110
+ ], [0, 1]);
111
+ if (result.code !== 0)
112
+ throw new Error('Invalid rule repository revision.');
113
+ }
114
+ await runGit(root, ['remote', 'add', 'origin', parsed.transport]);
115
+ await runGit(root, [
116
+ 'fetch', '--depth=1', '--no-tags', '--', 'origin', `${parsed.revision ?? 'HEAD'}:`,
117
+ ]);
118
+ await runGit(root, ['checkout', '--detach', 'FETCH_HEAD']);
119
+ return { root, cleanup };
120
+ }
121
+ catch (error) {
122
+ await cleanup();
123
+ throw error;
124
+ }
125
+ };
126
+ }
@@ -1,3 +1,4 @@
1
+ import type { LineRange } from '../../app/rule.js';
1
2
  export interface PullRequest {
2
3
  readonly repository: string;
3
4
  readonly number: number;
@@ -7,6 +8,7 @@ export interface PullRequestCheckout {
7
8
  readonly root: string;
8
9
  readonly rulesRoot: string;
9
10
  readonly changedFiles: ReadonlySet<string>;
11
+ readonly changedLines: ReadonlyMap<string, readonly LineRange[]>;
10
12
  readonly pullRequest: PullRequest;
11
13
  cleanup(): Promise<void>;
12
14
  }
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdtemp, realpath, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { changedFiles } from '../git/changes.js';
5
+ import { collectChanges } from '../git/changes.js';
6
6
  import { isolatedGitEnvironment } from '../git/environment.js';
7
7
  import { createGithubApi, mapping } from './api.js';
8
8
  export function repositoryName(value) {
@@ -127,9 +127,15 @@ export function createPullRequestCheckout(cwd, env) {
127
127
  const head = (await runGit(root, ['rev-parse', 'HEAD'])).stdout.trim();
128
128
  await runGit(root, ['worktree', 'add', '--detach', rulesRoot, 'refs/coderuler/base']);
129
129
  const mergeBase = (await runGit(root, ['merge-base', 'refs/coderuler/base', 'refs/coderuler/head'])).stdout.trim();
130
- const files = await changedFiles(root, mergeBase, isolatedEnv);
130
+ const changes = await collectChanges(root, mergeBase, isolatedEnv);
131
131
  await assertNoWorktreeOverride(rulesRoot);
132
- return { root, rulesRoot, changedFiles: files, pullRequest: { ...pullRequest, head }, cleanup };
132
+ return {
133
+ root,
134
+ rulesRoot,
135
+ ...changes,
136
+ pullRequest: { ...pullRequest, head },
137
+ cleanup,
138
+ };
133
139
  }
134
140
  catch (error) {
135
141
  await cleanup();
@@ -23,8 +23,11 @@ function assertExampleList(value, label) {
23
23
  }
24
24
  }
25
25
  function assertAiOptions(options) {
26
- if (typeof options.rule !== 'string' || options.rule === '') {
27
- fail('ai rule must be a non-empty string');
26
+ if (options.rule !== undefined && options.instruction !== undefined)
27
+ fail('ai rule and instruction are mutually exclusive');
28
+ const policy = options.instruction ?? options.rule;
29
+ if (typeof policy !== 'string' || policy === '') {
30
+ fail(`ai ${options.instruction === undefined ? 'rule' : 'instruction'} must be a non-empty string`);
28
31
  }
29
32
  const examples = options.examples;
30
33
  if (examples !== undefined) {
@@ -77,9 +80,13 @@ function projectState(subject) {
77
80
  function instructionsOf(options) {
78
81
  const good = options.examples?.good ?? [];
79
82
  const bad = options.examples?.bad ?? [];
83
+ const instructions = {
84
+ rule: options.instruction === undefined ? options.rule : options.instruction,
85
+ question: QUESTION,
86
+ };
80
87
  if (good.length === 0 && bad.length === 0)
81
- return { rule: options.rule, question: QUESTION };
82
- return { rule: options.rule, question: QUESTION, examples: { good, bad } };
88
+ return instructions;
89
+ return { ...instructions, examples: { good, bad } };
83
90
  }
84
91
  export async function evaluateAi(subject, options, context, runtime, observer) {
85
92
  assertAiOptions(options);
@@ -32,6 +32,7 @@ export async function executeRules(rules, context) {
32
32
  context.emit?.(event);
33
33
  };
34
34
  const changedFiles = context.changedFiles;
35
+ const changedLines = context.changedLines;
35
36
  const changedPaths = changedFiles === undefined
36
37
  ? undefined
37
38
  : [...changedFiles].filter(filepath => isEligibleRelativePath(filepath) && context.isTarget(filepath));
@@ -44,9 +45,11 @@ export async function executeRules(rules, context) {
44
45
  ...(details === undefined ? {} : { details }),
45
46
  });
46
47
  };
47
- const projectContextFor = (name) => ({
48
+ const projectContextFor = (name, evaluationId) => ({
48
49
  project: context.project,
49
- trace: traceFor(name),
50
+ ...(changedFiles === undefined ? {} : { changedFiles }),
51
+ ...(changedLines === undefined ? {} : { changedLines }),
52
+ trace: traceFor(name, evaluationId),
50
53
  });
51
54
  const fail = (name, stage, error, evaluationId) => {
52
55
  const message = error instanceof Error ? error.message : String(error);
@@ -106,7 +109,7 @@ export async function executeRules(rules, context) {
106
109
  const source = await context.project.readText(filepath);
107
110
  const matches = scope === undefined
108
111
  ? wholeFileScope({ filepath, source })
109
- : (await scope({ filepath, source }));
112
+ : (await scope({ filepath, source }, projectContextFor(name, evaluationId)));
110
113
  if (!Array.isArray(matches)) {
111
114
  throw new Error(`Rule "${name}" scope selector must return an array of scopes`);
112
115
  }
@@ -123,8 +126,7 @@ export async function executeRules(rules, context) {
123
126
  return collected.flat();
124
127
  };
125
128
  const selectionContextFor = (name, changedOnly) => ({
126
- project: context.project,
127
- trace: traceFor(name),
129
+ ...projectContextFor(name),
128
130
  select: selection => selectFromRule(name, selection, undefined, changedOnly),
129
131
  });
130
132
  const runtimeOf = (plan) => {
@@ -139,7 +141,6 @@ export async function executeRules(rules, context) {
139
141
  plan.evaluationStartedAt ??= performance.now();
140
142
  const { name, rule } = plan;
141
143
  emit({ type: 'evaluation', evaluationId, name, subject });
142
- const trace = traceFor(name, evaluationId);
143
144
  let trackedUsage;
144
145
  let pendingAi;
145
146
  let accountingFailure;
@@ -180,12 +181,11 @@ export async function executeRules(rules, context) {
180
181
  }
181
182
  };
182
183
  const checkContext = {
183
- project: context.project,
184
+ ...projectContextFor(name, evaluationId),
184
185
  ruleName: name,
185
- trace,
186
186
  select: selection => selectFromRule(name, selection, evaluationId),
187
187
  ai: options => {
188
- const call = evaluateAi(subject, options, { project: context.project, ruleName: name, trace }, runtimeOf(plan), observer);
188
+ const call = evaluateAi(subject, options, { ...projectContextFor(name, evaluationId), ruleName: name }, runtimeOf(plan), observer);
189
189
  const pending = pendingAi ??= new Set();
190
190
  pending.add(call);
191
191
  // Observe both outcomes immediately; the check still receives the original promise.
@@ -1,4 +1,8 @@
1
- import type { AiUsage, CheckResult, GroupSubject, JsonValue, Rule, RuleSettings, ScopeSubject, Subject, TokenPricing, TokenUsage } from './rule.js';
1
+ import type { AiUsage, CheckResult, GroupSubject, JsonValue, LineRange, Rule, RuleSettings, ScopeSubject, Subject, TokenPricing, TokenUsage } from './rule.js';
2
+ export interface DiffChanges {
3
+ readonly changedFiles: ReadonlySet<string>;
4
+ readonly changedLines: ReadonlyMap<string, readonly LineRange[]>;
5
+ }
2
6
  export interface Project {
3
7
  readonly root: string;
4
8
  glob(patterns: string | readonly string[]): Promise<readonly string[]>;
@@ -72,6 +76,7 @@ export interface RunContext {
72
76
  readonly project: Project;
73
77
  readonly isTarget: (filepath: string) => boolean;
74
78
  readonly changedFiles?: ReadonlySet<string>;
79
+ readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
75
80
  readonly concurrency: number;
76
81
  readonly resolveDecision: (settings: RuleSettings) => DecisionRuntime;
77
82
  readonly emit?: (event: RunEvent) => void;
@@ -9,6 +9,7 @@ export interface RuleTestResult {
9
9
  readonly name: string;
10
10
  readonly expected: RuleTestCase['expected'];
11
11
  readonly actual: RuleTestCase['expected'] | 'timeout';
12
+ readonly aiScores?: readonly number[];
12
13
  readonly durationMs: number;
13
14
  }
14
15
  export declare function test(name: string): {
@@ -11,15 +11,16 @@ export interface SourceFile {
11
11
  readonly filepath: string;
12
12
  readonly source: string;
13
13
  }
14
+ export interface LineRange {
15
+ readonly start: number;
16
+ readonly end: number;
17
+ }
14
18
  export interface ScopeMatch<T = JsonValue> {
15
19
  readonly id: string;
16
20
  readonly source: string;
17
21
  readonly data?: T;
18
22
  readonly captures?: Readonly<Record<string, string | readonly string[]>>;
19
- readonly lines?: {
20
- readonly start: number;
21
- readonly end: number;
22
- };
23
+ readonly lines?: LineRange;
23
24
  }
24
25
  export interface ScopeSubject<T = JsonValue> extends ScopeMatch<T> {
25
26
  readonly kind: 'scope';
@@ -34,13 +35,15 @@ export interface GroupSubject<T = JsonValue> {
34
35
  readonly members: readonly ScopeSubject<T>[];
35
36
  }
36
37
  export type Subject<T = JsonValue> = ScopeSubject<T> | GroupSubject<T>;
37
- export type ScopeSelector<T = JsonValue> = (file: SourceFile) => Awaitable<readonly ScopeMatch<T>[]>;
38
+ export type ScopeSelector<T = JsonValue> = {
39
+ bivarianceHack(file: SourceFile, context?: ProjectContext): Awaitable<readonly ScopeMatch<T>[]>;
40
+ }['bivarianceHack'];
38
41
  export type Grouping = 'scope' | 'file' | 'folder' | 'project' | {
39
42
  readonly by: 'folder';
40
43
  readonly level: number;
41
44
  };
42
45
  export interface RuleSettings {
43
- readonly backend?: 'jev';
46
+ readonly backend?: 'jev' | 'openai';
44
47
  readonly model?: string;
45
48
  readonly threshold?: number;
46
49
  readonly inputCostPerMillion?: number;
@@ -63,10 +66,7 @@ export interface AiUsage extends TokenUsage {
63
66
  export interface ViolationLocation {
64
67
  readonly filepath: string;
65
68
  readonly scopeId?: string;
66
- readonly lines?: {
67
- readonly start: number;
68
- readonly end: number;
69
- };
69
+ readonly lines?: LineRange;
70
70
  }
71
71
  export interface CheckResult {
72
72
  readonly status: 'pass' | 'violation';
@@ -81,15 +81,23 @@ export interface AiExamples {
81
81
  readonly good?: readonly string[];
82
82
  readonly bad?: readonly string[];
83
83
  }
84
- export interface AiOptions<T = JsonValue> {
85
- readonly rule: string;
84
+ export type AiOptions<T = JsonValue> = {
86
85
  readonly examples?: AiExamples;
87
86
  readonly violationMessage?: string;
88
87
  readonly input?: (subject: Subject<T>, context: ProjectContext) => Awaitable<JsonValue>;
89
- }
88
+ } & ({
89
+ /** @deprecated Use `instruction` instead. */
90
+ readonly rule: string;
91
+ readonly instruction?: never;
92
+ } | {
93
+ readonly instruction: string;
94
+ readonly rule?: never;
95
+ });
90
96
  export type Evaluator<T = JsonValue> = (subject: Subject<T>, context: CheckContext<T>) => Awaitable<CheckResult>;
91
97
  export interface ProjectContext {
92
98
  readonly project: Project;
99
+ readonly changedFiles?: ReadonlySet<string>;
100
+ readonly changedLines?: ReadonlyMap<string, readonly LineRange[]>;
93
101
  trace(message: string, details?: JsonValue): void;
94
102
  }
95
103
  export interface Selection<T = JsonValue> {
@@ -24,8 +24,9 @@ function isStringList(value, allowEmpty) {
24
24
  }
25
25
  function validateSettings(settings, at) {
26
26
  const backend = settings['backend'];
27
- if (backend !== undefined && backend !== 'jev')
28
- invalid(at, 'settings.backend must be "jev"');
27
+ if (backend !== undefined && backend !== 'jev' && backend !== 'openai') {
28
+ invalid(at, 'settings.backend must be "jev" or "openai"');
29
+ }
29
30
  const model = settings['model'];
30
31
  if (model !== undefined && (typeof model !== 'string' || model.trim() === '')) {
31
32
  invalid(at, 'settings.model must be a non-blank string');
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { Rule } 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, 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, Evaluator, Grouping, GroupSubject, JsonValue, PredicateResult, LineRange, ProjectContext, RuleSettings, ScopeMatch, ScopeSelector, ScopeSubject, Selection, SelectionContext, SourceFile, Subject, TokenPricing, TokenUsage, ViolationLocation, } 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, 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, 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';
@@ -17,7 +17,7 @@ function loadEnvironment(cwd) {
17
17
  }
18
18
  }
19
19
  export function createServices(cwd, env) {
20
- const redact = createRedactor([env.TYPESAFE_API_KEY, env.GITHUB_TOKEN]);
20
+ const redact = createRedactor([env.TYPESAFE_API_KEY, env.OPENAI_API_KEY, env.GITHUB_TOKEN]);
21
21
  let dependenciesPromise;
22
22
  let ruleServicesPromise;
23
23
  const getDependencies = (context) => dependenciesPromise ??= (async () => {
@@ -69,14 +69,18 @@ export function createServices(cwd, env) {
69
69
  resolveTarget: project.resolveTarget,
70
70
  runRules: execution.executeRules,
71
71
  decisionResolver: (thresholdOverride, pricingOverrides) => settings.createDecisionResolver(env, thresholdOverride, pricingOverrides),
72
- async changedFiles(root, base) {
73
- const { changedFiles } = await import('../adapter/git/changes.js');
74
- return changedFiles(root, base);
72
+ async collectChanges(root, base) {
73
+ const { collectChanges } = await import('../adapter/git/changes.js');
74
+ return collectChanges(root, base);
75
75
  },
76
76
  async checkoutPullRequest(selector, baseBranch) {
77
77
  const { createPullRequestCheckout } = await import('../adapter/github/pull-request.js');
78
78
  return createPullRequestCheckout(cwd, env)(selector, baseBranch);
79
79
  },
80
+ async checkoutRuleRepository(selector) {
81
+ const { createRuleRepositoryCheckout } = await import('../adapter/git/rules.js');
82
+ return createRuleRepositoryCheckout(env)(selector);
83
+ },
80
84
  async commentOnPullRequest(root, comments, pullRequest) {
81
85
  const { createGithubCommenter } = await import('../adapter/github/comments.js');
82
86
  return createGithubCommenter(env)(root, comments, pullRequest);
@@ -160,7 +164,11 @@ export async function runCli(argv) {
160
164
  }
161
165
  catch (error) {
162
166
  const message = error instanceof Error ? error.message : String(error);
163
- const redact = createRedactor([process.env.TYPESAFE_API_KEY, process.env.GITHUB_TOKEN]);
167
+ const redact = createRedactor([
168
+ process.env.TYPESAFE_API_KEY,
169
+ process.env.OPENAI_API_KEY,
170
+ process.env.GITHUB_TOKEN,
171
+ ]);
164
172
  process.stderr.write(`coderuler: ${redact(escapeDiagnostic(message))}\n`);
165
173
  return EXIT_FAILURE;
166
174
  }
@@ -1,9 +1,13 @@
1
1
  import { JevAdapter } from '../adapter/decision/jev.js';
2
+ import { OpenAIAdapter } from '../adapter/decision/openai.js';
2
3
  const DEFAULT_BACKEND = 'jev';
3
4
  const DEFAULT_MODEL = 'jev-latest';
4
5
  const DEFAULT_BASE_URL = 'https://api.typesafe.ai';
6
+ const OPENAI_DEFAULT_MODEL = 'gpt-6-luna';
7
+ const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
5
8
  const DEFAULT_THRESHOLD = 0.4;
6
9
  const DEFAULT_INPUT_COST_PER_MILLION = 0.042;
10
+ const OPENAI_DEFAULT_INPUT_COST_PER_MILLION = 0.10;
7
11
  const DEFAULT_OUTPUT_COST_PER_MILLION = 0;
8
12
  const THRESHOLD_MESSAGE = 'must be a number between 0 and 1';
9
13
  const DECIMAL = /^(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/;
@@ -56,9 +60,10 @@ export function createDecisionResolver(env, thresholdOverride, pricingOverrides)
56
60
  validatePrice(outputOverride, '--output-cost-per-million');
57
61
  return settings => {
58
62
  const backend = settings.backend ?? setting(env, 'RK_BACKEND') ?? DEFAULT_BACKEND;
59
- if (backend !== 'jev') {
60
- throw new Error(`Unknown decision backend '${backend}'; supported backends: ${DEFAULT_BACKEND}`);
63
+ if (backend !== 'jev' && backend !== 'openai') {
64
+ throw new Error(`Unknown decision backend '${backend}'; supported backends: jev, openai`);
61
65
  }
66
+ const isOpenAI = backend === 'openai';
62
67
  let threshold;
63
68
  if (flagThreshold !== undefined) {
64
69
  threshold = flagThreshold;
@@ -77,14 +82,20 @@ export function createDecisionResolver(env, thresholdOverride, pricingOverrides)
77
82
  validatePrice(settings.outputCostPerMillion, 'rule.settings.outputCostPerMillion');
78
83
  }
79
84
  const pricing = {
80
- inputCostPerMillion: price(inputOverride, settings.inputCostPerMillion, env, 'RK_INPUT_COST_PER_MILLION', DEFAULT_INPUT_COST_PER_MILLION),
85
+ inputCostPerMillion: price(inputOverride, settings.inputCostPerMillion, env, 'RK_INPUT_COST_PER_MILLION', isOpenAI ? OPENAI_DEFAULT_INPUT_COST_PER_MILLION : DEFAULT_INPUT_COST_PER_MILLION),
81
86
  outputCostPerMillion: price(outputOverride, settings.outputCostPerMillion, env, 'RK_OUTPUT_COST_PER_MILLION', DEFAULT_OUTPUT_COST_PER_MILLION),
82
87
  };
83
- const model = settings.model ?? setting(env, 'TYPESAFE_DEFAULT_MODEL') ?? DEFAULT_MODEL;
84
- const baseUrl = setting(env, 'TYPESAFE_BASE_URL') ?? DEFAULT_BASE_URL;
85
- const apiKey = setting(env, 'TYPESAFE_API_KEY');
86
- if (apiKey === undefined) {
87
- throw new Error('No decision API key: set TYPESAFE_API_KEY');
88
+ const model = settings.model ??
89
+ setting(env, isOpenAI ? 'OPENAI_DEFAULT_MODEL' : 'TYPESAFE_DEFAULT_MODEL') ??
90
+ (isOpenAI ? OPENAI_DEFAULT_MODEL : DEFAULT_MODEL);
91
+ const baseUrl = setting(env, isOpenAI ? 'OPENAI_BASE_URL' : 'TYPESAFE_BASE_URL') ??
92
+ (isOpenAI ? OPENAI_DEFAULT_BASE_URL : DEFAULT_BASE_URL);
93
+ const apiKeyName = isOpenAI ? 'OPENAI_API_KEY' : 'TYPESAFE_API_KEY';
94
+ const apiKey = setting(env, apiKeyName);
95
+ if (apiKey === undefined)
96
+ throw new Error(`No decision API key: set ${apiKeyName}`);
97
+ if (isOpenAI) {
98
+ return { backend: new OpenAIAdapter({ model, baseUrl, apiKey }), threshold, pricing };
88
99
  }
89
100
  return { backend: new JevAdapter({ model, baseUrl, apiKey }), threshold, pricing };
90
101
  };
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
1
  {
2
2
  "name": "@alex-dbs/coderuler",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "license": "Apache-2.0",
5
+ "keywords": [
6
+ "ai",
7
+ "ai-agent",
8
+ "agent",
9
+ "rule",
10
+ "rules",
11
+ "coding-standards",
12
+ "standard",
13
+ "code-quality",
14
+ "code-review",
15
+ "security",
16
+ "linter",
17
+ "checker",
18
+ "static-analysis",
19
+ "policy-as-code",
20
+ "typescript",
21
+ "developer-tools"
22
+ ],
5
23
  "repository": {
6
24
  "type": "git",
7
25
  "url": "git+https://github.com/alex-dbs/coderuler.git"
@@ -72,6 +90,7 @@
72
90
  "jsonpath-plus": "11.2.1",
73
91
  "npm-install-checks": "9.0.0",
74
92
  "npm-package-arg": "14.0.0",
93
+ "openai": "7.45.1",
75
94
  "pacote": "22.0.0",
76
95
  "skills": "1.7.2",
77
96
  "ssri": "14.0.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: coderuler-new-rule
3
- description: Create CodeRuler rules, including custom selection, filters and checks, inline tests, and inspection.
3
+ description: Create CodeRuler rules, including custom selection, filters and checks, inline or companion tests, and inspection.
4
4
  ---
5
5
 
6
6
  # Create a CodeRuler rule
@@ -29,7 +29,13 @@ Start the name with its language or technology prefix, such as `go-`, `ts-`, or
29
29
 
30
30
  Keep rules short, with only a few lines of policy code. Reuse built-in selectors, evaluators, and standard libraries before writing custom logic. Add helpers only for real reuse.
31
31
 
32
- Add a named `tests` export in the same file. Use `.passes()` and `.violates()` cases that exercise the policy. Omit `.skips()` assertions when creating a rule: skipped inputs never reach the check and provide no evidence of policy behavior.
32
+ Test cases can live in the rule module or in a sibling `.test` module. For example, `sample.rule.ts` pairs with `sample.test.ts`; keep the same extension for `.mts`, `.js`, and `.mjs`. The `test` command combines exports from both modules. It runs ordinary `tests` by default; include `--ai` to run `aiTests`.
33
+
34
+ For a deterministic rule, export named `tests` with `.passes()` and `.violates()` cases. For an AI rule, put every test case in `aiTests` and do not add a separate `tests` export. Omit `.skips()` assertions when creating a rule: skipped inputs never reach the check and provide no evidence of policy behavior.
35
+
36
+ For an AI rule, use `instruction` to state what compliant code does, beginning with a direct requirement. Add only applicability boundaries or real exceptions needed to define the policy; keep one policy per rule. Keep reporting language in `violationMessage`, examples in `examples`, and test cases in `aiTests`—not in the instruction. See [AI instruction style](reference.md#ai-instruction-style).
37
+
38
+ Keep Jev's input to the smallest scope that still supports a correct decision. For function-local code rules, use `scopes.astGrep` to select one function or method per scope and filter for relevant syntax, such as pointer dereferences.
33
39
 
34
40
  Run the relevant cases before the policy change and require an assertion mismatch for the intended violation, not a loading error. Implement the policy, then require every case to pass.
35
41
 
@@ -90,17 +96,17 @@ coderuler validate
90
96
  coderuler test
91
97
  ```
92
98
 
93
- `validate` checks the module contract, not policy behavior or TypeScript semantics. `check` and `validate` do not execute inline tests. `test` with no selected cases is an error, not success.
99
+ `validate` checks the module contract, not policy behavior or TypeScript semantics. `check` and `validate` do not run test cases or load sibling `.test` modules. `test` with no selected cases is an error, not success.
94
100
 
95
101
  Useful test options:
96
102
 
97
103
  ```sh
98
104
  coderuler test . --failures-only
99
105
  coderuler test . --concurrency 4 --timeout 30s
100
- coderuler test .coderuler/k8s-semantic-policy.rule.ts --ai --timeout 45s
106
+ coderuler test .coderuler/semantic-policy.rule.ts --ai --ai-scores --timeout 45s
101
107
  ```
102
108
 
103
- Replace the semantic-policy filename with the actual AI rule. `--ai` authorizes live provider calls for `aiTests`, requires provider configuration, and may incur charges. Outcomes can vary; provider errors are errors, not passes.
109
+ Replace the semantic-policy filename with the actual AI rule. `--ai` authorizes live provider calls for `aiTests`, requires provider configuration, and can incur charges. `--ai-scores` displays each Jev probability; use it with `--ai`. A test marked `PASS` means its expectation matched, not that its code is compliant. Expected violations need scores near `0`; compliant cases need scores near `1`. For wording changes, compare both groups on the same fixtures and configuration. Follow [the comparison procedure](reference.md#ai-instruction-style) rather than changing fixtures or thresholds to make a candidate pass.
104
110
 
105
111
  ## 4. Inspect the rule on the project
106
112