@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.
- package/README.md +8 -3
- package/dist/adapter/cli/commands/check.js +21 -10
- package/dist/adapter/cli/commands/test.js +2 -2
- package/dist/adapter/cli/program.js +2 -1
- package/dist/adapter/cli/reports.d.ts +1 -1
- package/dist/adapter/cli/reports.js +5 -2
- package/dist/adapter/cli/services.d.ts +5 -2
- package/dist/adapter/decision/jev.js +32 -15
- package/dist/adapter/decision/openai.d.ts +11 -0
- package/dist/adapter/decision/openai.js +160 -0
- package/dist/adapter/filesystem/rule-test-worker.d.ts +6 -1
- package/dist/adapter/filesystem/rule-test-worker.js +13 -3
- package/dist/adapter/filesystem/rule-tests.d.ts +1 -1
- package/dist/adapter/filesystem/rule-tests.js +69 -21
- package/dist/adapter/git/changes.d.ts +2 -1
- package/dist/adapter/git/changes.js +369 -35
- package/dist/adapter/git/rules.d.ts +5 -0
- package/dist/adapter/git/rules.js +126 -0
- package/dist/adapter/github/pull-request.d.ts +2 -0
- package/dist/adapter/github/pull-request.js +9 -3
- package/dist/app/evaluators.js +11 -4
- package/dist/app/execute.js +9 -9
- package/dist/app/ports.d.ts +6 -1
- package/dist/app/rule-tests.d.ts +1 -0
- package/dist/app/rule.d.ts +21 -13
- package/dist/app/validate.js +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/infra/runtime.js +13 -5
- package/dist/infra/settings.js +19 -8
- package/package.json +20 -1
- package/skills/coderuler-new-rule/SKILL.md +11 -5
- package/skills/coderuler-new-rule/reference.md +121 -53
|
@@ -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 {
|
|
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
|
|
130
|
+
const changes = await collectChanges(root, mergeBase, isolatedEnv);
|
|
131
131
|
await assertNoWorktreeOverride(rulesRoot);
|
|
132
|
-
return {
|
|
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();
|
package/dist/app/evaluators.js
CHANGED
|
@@ -23,8 +23,11 @@ function assertExampleList(value, label) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
function assertAiOptions(options) {
|
|
26
|
-
if (
|
|
27
|
-
fail('ai rule
|
|
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
|
|
82
|
-
return {
|
|
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);
|
package/dist/app/execute.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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.
|
package/dist/app/ports.d.ts
CHANGED
|
@@ -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;
|
package/dist/app/rule-tests.d.ts
CHANGED
|
@@ -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): {
|
package/dist/app/rule.d.ts
CHANGED
|
@@ -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> =
|
|
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
|
|
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> {
|
package/dist/app/validate.js
CHANGED
|
@@ -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';
|
package/dist/infra/runtime.js
CHANGED
|
@@ -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
|
|
73
|
-
const {
|
|
74
|
-
return
|
|
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([
|
|
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
|
}
|
package/dist/infra/settings.js
CHANGED
|
@@ -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:
|
|
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 ??
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|