@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 CHANGED
@@ -15,6 +15,7 @@ Install the CLI globally:
15
15
  ```sh
16
16
  npm install -g @alex-dbs/coderuler
17
17
  coderuler --help
18
+ coderuler skill install
18
19
  ```
19
20
 
20
21
  The npm package is scoped. The command is `coderuler`. A global installation can load rules without a project-local dependency.
@@ -24,6 +25,7 @@ For a project-specific CLI version, install it locally:
24
25
  ```sh
25
26
  npm install --save-dev @alex-dbs/coderuler
26
27
  npx coderuler --help
28
+ npx coderuler skill install
27
29
  ```
28
30
 
29
31
  The global installation is optional: with only the project-local dependency, run commands through `npx coderuler`.
@@ -114,14 +116,17 @@ export default class SafeResponseHeaders extends Rule {
114
116
  });
115
117
 
116
118
  check = ai({
117
- rule: 'Report a violation only when this function writes request data into an HTTP response header without rejecting CR and LF first. Judge only this function. Do not assume unseen callers or helpers validate the data.',
119
+ instruction: `Reject CR and LF in untrusted values before inserting them into
120
+ HTTP response headers or status lines.
121
+ This policy covers response construction and writes visible in the selected function. Hidden helper behavior and unseen callers are outside its scope.
122
+ Treat serialization arguments and statusMessage as untrusted. Fixed values and values checked for both CR and LF meet the requirement. Preserve fixed HTTP delimiters.`,
118
123
  input: subject => (subject as ScopeSubject).source,
119
124
  violationMessage: 'Reject CR and LF in request data before writing a response header.',
120
125
  });
121
126
  }
122
127
  ```
123
128
 
124
- This example shows an AI check for CWE-113.
129
+ This example shows an AI check for CWE-113.
125
130
 
126
131
  AI rules require a configured service and can incur charges.
127
132
 
@@ -155,7 +160,7 @@ A scope is a file or selected part of a file. CodeRuler validates selected rules
155
160
  The `settings` field sets AI options for one rule, such as its model, decision threshold, and input or output token prices.
156
161
 
157
162
  > [!TIP]
158
- > Browse the [`.coderuler` folder](.coderuler) for rule examples.
163
+ > Browse the [`.coderuler.examples` folder](.coderuler.examples) for rule examples.
159
164
 
160
165
  ## Commands
161
166
 
@@ -12,6 +12,7 @@ async function runCheck(target, options, context, services) {
12
12
  ? createProgress(context.writeError, () => context.terminal?.columns ?? 80)
13
13
  : undefined;
14
14
  let checkout;
15
+ let rulesCheckout;
15
16
  try {
16
17
  let reports;
17
18
  let durationMs;
@@ -24,21 +25,28 @@ async function runCheck(target, options, context, services) {
24
25
  }
25
26
  const area = await services.resolveTarget(checkout?.root ?? context.cwd, target);
26
27
  root = area.root;
27
- let changedFiles = checkout?.changedFiles ?? (diffOnly
28
- ? await services.changedFiles(area.root, options.base ?? 'HEAD')
29
- : undefined);
30
- const choice = options.rules === undefined
31
- ? {}
32
- : await services.resolveRuleSelector(options.rules, context.cwd, true);
28
+ let changes = checkout === undefined
29
+ ? (diffOnly ? await services.collectChanges(area.root, options.base ?? 'HEAD') : undefined)
30
+ : { changedFiles: checkout.changedFiles, changedLines: checkout.changedLines };
31
+ let choice = {};
32
+ if (options.rules !== undefined) {
33
+ rulesCheckout = await services.checkoutRuleRepository(options.rules);
34
+ choice = rulesCheckout === undefined
35
+ ? await services.resolveRuleSelector(options.rules, context.cwd, true)
36
+ : { explicit: rulesCheckout.root };
37
+ }
33
38
  const prepared = await planWalk(context.cwd, services, area.root, filepath => area.contains(filepath), choice, checkout?.rulesRoot ?? area.root);
34
39
  rules = options.tags.length === 0 && options.excludeTags.length === 0
35
40
  ? prepared.rules
36
41
  : prepared.rules.filter(({ rule }) => (options.tags.length === 0 ||
37
42
  rule.tags?.some(tag => options.tags.includes(tag)) === true) &&
38
43
  !rule.tags?.some(tag => options.excludeTags.includes(tag)));
39
- if (changedFiles !== undefined) {
44
+ if (changes !== undefined) {
40
45
  const ruleFiles = new Set(prepared.rules.map(entry => relative(area.root, resolve(entry.ruleFile)).split(sep).join('/')));
41
- changedFiles = new Set([...changedFiles].filter(path => !ruleFiles.has(path)));
46
+ changes = {
47
+ changedFiles: new Set([...changes.changedFiles].filter(path => !ruleFiles.has(path))),
48
+ changedLines: new Map([...changes.changedLines].filter(([path]) => !ruleFiles.has(path))),
49
+ };
42
50
  }
43
51
  const concurrency = debug ? 1 : options.concurrency;
44
52
  walkthrough = verbose
@@ -59,7 +67,10 @@ async function runCheck(target, options, context, services) {
59
67
  concurrency,
60
68
  resolveDecision: services.decisionResolver(options.threshold),
61
69
  ...(emit === undefined ? {} : { emit }),
62
- ...(changedFiles === undefined ? {} : { changedFiles }),
70
+ ...(changes === undefined ? {} : {
71
+ changedFiles: changes.changedFiles,
72
+ changedLines: changes.changedLines,
73
+ }),
63
74
  };
64
75
  const startedAt = performance.now();
65
76
  reports = await services.runRules(rules, runContext);
@@ -87,7 +98,7 @@ async function runCheck(target, options, context, services) {
87
98
  }
88
99
  }
89
100
  finally {
90
- await checkout?.cleanup();
101
+ await Promise.all([checkout?.cleanup(), rulesCheckout?.cleanup()]);
91
102
  }
92
103
  }
93
104
  export { runCheck };
@@ -22,7 +22,7 @@ async function runTest(rulePath, options, context, services) {
22
22
  context.write(renderRuleTestHeading(ruleName, context.redact, color));
23
23
  previousRuleName = ruleName;
24
24
  for (let index = 0; index < failures.length; index += 1) {
25
- context.write(renderRuleTestResult(failures[index], context.redact, color, index === failures.length - 1));
25
+ context.write(renderRuleTestResult(failures[index], context.redact, color, index === failures.length - 1, options.aiScores === true));
26
26
  }
27
27
  failures.length = 0;
28
28
  };
@@ -53,7 +53,7 @@ async function runTest(rulePath, options, context, services) {
53
53
  context.write(renderRuleTestHeading(result.ruleName, context.redact, color));
54
54
  previousRuleName = result.ruleName;
55
55
  }
56
- context.write(renderRuleTestResult(result, context.redact, color, lastInRule));
56
+ context.write(renderRuleTestResult(result, context.redact, color, lastInRule, options.aiScores === true));
57
57
  }
58
58
  progress?.message(`Running tests (${passed + failed} completed)`);
59
59
  }, options.timeout, options.concurrency, options.ai === true);
@@ -128,6 +128,7 @@ export function createProgram(services) {
128
128
  .description('Run the declared test cases for the selected rules')
129
129
  .argument('[rule]', 'rule name, module path, or directory')
130
130
  .option('--ai', 'include aiTests that use the configured decision backend')
131
+ .option('--ai-scores', 'show AI scores for aiTests')
131
132
  .option('--failures-only', 'show only failed test cases')
132
133
  .addOption(new Option('--timeout <duration>', 'per-test timeout (ms, s, or m)')
133
134
  .argParser(parseTimeout)
@@ -142,7 +143,7 @@ export function createProgram(services) {
142
143
  .command('check')
143
144
  .description('Evaluate every selected rule against the working tree')
144
145
  .argument('[path]', 'target file or folder that narrows selection', '.')
145
- .option('--rules <rule>', 'rule name, module path, or directory')
146
+ .option('--rules <rule>', 'rule name, module path, directory, or Git repository URL[#revision]')
146
147
  .option('--tags <tags...>', 'run rules matching any listed tag', [])
147
148
  .option('--exclude-tags <tags...>', 'exclude rules matching any listed tag', [])
148
149
  .option('--json', 'print violations as {durationMs, reports} JSON on stdout')
@@ -4,7 +4,7 @@ import type { ViolationComment } from '../../app/violation-comments.js';
4
4
  import { type TextRedactor, type TextWriter } from './text.js';
5
5
  export type RuleHeadings = (name: string) => readonly string[];
6
6
  export declare function renderRuleTestHeading(ruleName: string, redact: TextRedactor, color: boolean): string;
7
- export declare function renderRuleTestResult(result: RuleTestResult, redact: TextRedactor, color: boolean, lastInRule: boolean): string;
7
+ export declare function renderRuleTestResult(result: RuleTestResult, redact: TextRedactor, color: boolean, lastInRule: boolean, showAIScores?: boolean): string;
8
8
  export declare function renderRuleTestSummary(passed: number, failed: number, durationMs: number, redact: TextRedactor, color: boolean): string;
9
9
  export declare function createRuleHeadings(rules: readonly LoadedRule[]): RuleHeadings;
10
10
  export declare function createMetadataRenderer(write: TextWriter, redact: TextRedactor): (event: RunEvent) => void;
@@ -36,7 +36,7 @@ function sourceText(line) {
36
36
  export function renderRuleTestHeading(ruleName, redact, color) {
37
37
  return `${paint(escapeText(redact(ruleName)), 'bold', color)}\n`;
38
38
  }
39
- export function renderRuleTestResult(result, redact, color, lastInRule) {
39
+ export function renderRuleTestResult(result, redact, color, lastInRule, showAIScores = false) {
40
40
  const safe = (text) => escapeText(redact(text));
41
41
  const matches = result.expected === result.actual;
42
42
  const label = paint(safe(matches ? 'PASS' : 'FAIL'), matches ? 'green' : 'red', color);
@@ -44,8 +44,11 @@ export function renderRuleTestResult(result, redact, color, lastInRule) {
44
44
  const detail = matches
45
45
  ? ''
46
46
  : safe(` (expected ${result.expected}, actual ${result.actual})`);
47
+ const aiScores = showAIScores && result.aiScores !== undefined && result.aiScores.length > 0
48
+ ? ` (${result.aiScores.length === 1 ? 'AI score' : 'AI scores'}: ${result.aiScores.join(', ')})`
49
+ : '';
47
50
  const duration = paint(safe(` (${formatDuration(result.durationMs)})`), 'dim', color);
48
- return `${branch}${label} ${safe(result.name)}${detail}${duration}\n`;
51
+ return `${branch}${label} ${safe(result.name)}${detail}${aiScores}${duration}\n`;
49
52
  }
50
53
  export function renderRuleTestSummary(passed, failed, durationMs, redact, color) {
51
54
  const safe = (text) => escapeText(redact(text));
@@ -1,8 +1,9 @@
1
- import type { LoadedRule, Project, RuleReport, RuleSelection, RunContext } from '../../app/ports.js';
1
+ import type { DiffChanges, LoadedRule, Project, RuleReport, RuleSelection, RunContext } from '../../app/ports.js';
2
2
  import type { RuleTestResult } from '../../app/rule-tests.js';
3
3
  import type { TokenPricing } from '../../app/rule.js';
4
4
  import type { ViolationComment } from '../../app/violation-comments.js';
5
5
  import type { PullRequest, PullRequestCheckout } from '../github/pull-request.js';
6
+ import type { RuleRepositoryCheckout } from '../git/rules.js';
6
7
  import type { SkillOptions } from '../skills/cli.js';
7
8
  import type { TerminalCapabilities } from './progress.js';
8
9
  import type { TextRedactor, TextWriter } from './text.js';
@@ -44,6 +45,7 @@ export interface TestOptions {
44
45
  readonly concurrency: number;
45
46
  readonly ai?: boolean;
46
47
  readonly failuresOnly?: boolean;
48
+ readonly aiScores?: boolean;
47
49
  }
48
50
  export interface DependencyOptions {
49
51
  readonly global?: boolean;
@@ -70,8 +72,9 @@ export interface ExecutionServices extends RuleServices {
70
72
  readonly decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial<TokenPricing>) => RunContext['resolveDecision'];
71
73
  }
72
74
  export interface CheckServices extends ExecutionServices {
73
- readonly changedFiles: (root: string, base: string) => Promise<ReadonlySet<string>>;
75
+ readonly collectChanges: (root: string, base: string) => Promise<DiffChanges>;
74
76
  readonly checkoutPullRequest: (selector: string, baseBranch?: string) => Promise<PullRequestCheckout>;
77
+ readonly checkoutRuleRepository: (selector: string) => Promise<RuleRepositoryCheckout | undefined>;
75
78
  readonly commentOnPullRequest: (root: string, comments: readonly ViolationComment[], pullRequest?: PullRequest) => Promise<void>;
76
79
  }
77
80
  export type InspectServices = ExecutionServices;
@@ -1,6 +1,7 @@
1
1
  const ENDPOINT = 'v1/systemone';
2
2
  const QUESTION_ID = 'follows_instructions';
3
3
  const TIMEOUT_MS = 30_000;
4
+ const RETRY_DELAYS_MS = [3_000, 10_000, 30_000];
4
5
  const REDACTED = '[REDACTED]';
5
6
  const URL_LABEL = 'Decision base URL (TYPESAFE_BASE_URL)';
6
7
  function isMapping(value) {
@@ -53,6 +54,14 @@ function requestFailure(error, axios) {
53
54
  }
54
55
  return new Error(`Decision request failed: ${error instanceof Error ? error.message : String(error)}`);
55
56
  }
57
+ function shouldRetry(error, axios) {
58
+ if (!axios.isAxiosError(error))
59
+ return false;
60
+ const status = error.response?.status;
61
+ return status === undefined
62
+ ? error.request !== undefined
63
+ : status === 408 || status === 429 || (status >= 500 && status < 600);
64
+ }
56
65
  function readDecision(data) {
57
66
  if (!isMapping(data)) {
58
67
  throw new Error('Decision response is not a JSON object');
@@ -114,24 +123,32 @@ export class JevAdapter {
114
123
  },
115
124
  };
116
125
  const wire = JSON.stringify(body);
117
- observer?.({ type: 'request', body: redact(wire, this.#needles) });
118
126
  let data;
119
- try {
120
- const response = await client.post(ENDPOINT, wire, {
121
- headers: { 'Content-Type': 'application/json' },
122
- });
123
- data = response.data;
124
- observer?.({ type: 'response', status: response.status, body: redact(JSON.stringify(data) ?? '', this.#needles) });
125
- }
126
- catch (error) {
127
- if (axios.isAxiosError(error) && error.response !== undefined) {
128
- observer?.({
129
- type: 'response',
130
- status: error.response.status,
131
- body: redact(JSON.stringify(error.response.data) ?? '', this.#needles),
127
+ for (let retry = 0;; retry += 1) {
128
+ observer?.({ type: 'request', body: redact(wire, this.#needles) });
129
+ try {
130
+ const response = await client.post(ENDPOINT, wire, {
131
+ headers: { 'Content-Type': 'application/json' },
132
132
  });
133
+ data = response.data;
134
+ observer?.({ type: 'response', status: response.status, body: redact(JSON.stringify(data) ?? '', this.#needles) });
135
+ break;
136
+ }
137
+ catch (error) {
138
+ if (axios.isAxiosError(error) && error.response !== undefined) {
139
+ observer?.({
140
+ type: 'response',
141
+ status: error.response.status,
142
+ body: redact(JSON.stringify(error.response.data) ?? '', this.#needles),
143
+ });
144
+ }
145
+ const delayMs = RETRY_DELAYS_MS[retry];
146
+ if (delayMs === undefined || !shouldRetry(error, axios))
147
+ throw requestFailure(error, axios);
148
+ const delay = Promise.withResolvers();
149
+ setTimeout(delay.resolve, delayMs);
150
+ await delay.promise;
133
151
  }
134
- throw requestFailure(error, axios);
135
152
  }
136
153
  return readDecision(data);
137
154
  }
@@ -0,0 +1,11 @@
1
+ import type { DecisionBackend, DecisionObserver, DecisionOutcome, DecisionRequest } from '../../app/ports.js';
2
+ export interface OpenAIOptions {
3
+ readonly model: string;
4
+ readonly baseUrl: string;
5
+ readonly apiKey: string;
6
+ }
7
+ export declare class OpenAIAdapter implements DecisionBackend {
8
+ #private;
9
+ constructor({ model, baseUrl, apiKey }: OpenAIOptions);
10
+ decide(request: DecisionRequest, observer?: DecisionObserver): Promise<DecisionOutcome>;
11
+ }
@@ -0,0 +1,160 @@
1
+ import { isRecord } from '../../app/validate.js';
2
+ const QUESTION_NAME = 'follows_instructions';
3
+ const TIMEOUT_MS = 30_000;
4
+ const REDACTED = '[REDACTED]';
5
+ const URL_LABEL = 'Decision base URL (OPENAI_BASE_URL)';
6
+ function baseUrlOf(baseUrl) {
7
+ let url;
8
+ try {
9
+ url = new URL(baseUrl);
10
+ }
11
+ catch {
12
+ throw new Error(`${URL_LABEL} is not an absolute URL`);
13
+ }
14
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
15
+ throw new Error(`${URL_LABEL} must use http or https`);
16
+ }
17
+ if (baseUrl.includes('?') || baseUrl.includes('#')) {
18
+ throw new Error(`${URL_LABEL} must not carry a query or a fragment`);
19
+ }
20
+ if (url.username !== '' || url.password !== '') {
21
+ throw new Error(`${URL_LABEL} must not embed credentials`);
22
+ }
23
+ return baseUrl;
24
+ }
25
+ function redactionNeedles(apiKey) {
26
+ if (apiKey === '')
27
+ return [];
28
+ const escaped = JSON.stringify(apiKey).slice(1, -1);
29
+ return escaped === apiKey ? [apiKey] : [escaped, apiKey];
30
+ }
31
+ function redact(text, needles) {
32
+ let redacted = text;
33
+ for (const needle of needles)
34
+ redacted = redacted.replaceAll(needle, REDACTED);
35
+ return redacted;
36
+ }
37
+ function formattedInstructions(request) {
38
+ const { rule, question, examples } = request.instructions;
39
+ const sections = [`Rule:\n${rule}`];
40
+ if (examples?.good !== undefined && examples.good.length > 0) {
41
+ sections.push(`Good examples (the rule is respected):\n${JSON.stringify(examples.good)}`);
42
+ }
43
+ if (examples?.bad !== undefined && examples.bad.length > 0) {
44
+ sections.push(`Bad examples (the rule is not respected):\n${JSON.stringify(examples.bad)}`);
45
+ }
46
+ sections.push(question);
47
+ return sections.join('\n\n');
48
+ }
49
+ function readDecision(data) {
50
+ if (!isRecord(data))
51
+ throw new Error('Decision response is not a JSON object');
52
+ const model = data.model;
53
+ if (typeof model !== 'string' || model.trim() === '') {
54
+ throw new Error('Decision response carries no model');
55
+ }
56
+ const answers = data.answers;
57
+ if (!Array.isArray(answers))
58
+ throw new Error('Decision response carries no answers');
59
+ const answer = answers.find((candidate) => isRecord(candidate) && candidate.name === QUESTION_NAME);
60
+ if (!isRecord(answer)) {
61
+ throw new Error(`Decision response carries no '${QUESTION_NAME}' answer`);
62
+ }
63
+ if (answer.type === 'refusal') {
64
+ throw new Error(`Decision response '${QUESTION_NAME}' answer was refused`);
65
+ }
66
+ if (answer.type !== 'predicate') {
67
+ throw new Error(`Decision response '${QUESTION_NAME}' answer is not a predicate answer`);
68
+ }
69
+ const probability = answer.probability;
70
+ if (typeof probability !== 'number' || !Number.isFinite(probability) || probability < 0 || probability > 1) {
71
+ throw new Error(`Decision response '${QUESTION_NAME}' answer is not a probability between 0 and 1`);
72
+ }
73
+ const rawUsage = data.usage;
74
+ if (rawUsage === undefined)
75
+ return { probability, model };
76
+ if (!isRecord(rawUsage))
77
+ throw new Error('Decision response usage must be an object');
78
+ const inputTokens = rawUsage.input_tokens;
79
+ const outputTokens = rawUsage.output_tokens;
80
+ if (typeof inputTokens !== 'number' || !Number.isSafeInteger(inputTokens) || inputTokens < 0 ||
81
+ typeof outputTokens !== 'number' || !Number.isSafeInteger(outputTokens) || outputTokens < 0) {
82
+ throw new Error('Decision response usage must carry nonnegative safe integer input_tokens and output_tokens');
83
+ }
84
+ return { probability, model, usage: { inputTokens, outputTokens } };
85
+ }
86
+ export class OpenAIAdapter {
87
+ #client;
88
+ #apiKey;
89
+ #baseUrl;
90
+ #model;
91
+ #needles;
92
+ constructor({ model, baseUrl, apiKey }) {
93
+ this.#model = model;
94
+ this.#baseUrl = baseUrlOf(baseUrl);
95
+ this.#apiKey = apiKey;
96
+ this.#needles = redactionNeedles(apiKey);
97
+ }
98
+ async decide(request, observer) {
99
+ const payload = {
100
+ model: this.#model,
101
+ input: JSON.stringify(request.state),
102
+ questions: [{
103
+ type: 'predicate',
104
+ name: QUESTION_NAME,
105
+ instructions: formattedInstructions(request),
106
+ }],
107
+ };
108
+ const observedPayload = {
109
+ ...payload,
110
+ input: redact(payload.input, this.#needles),
111
+ questions: payload.questions.map(question => ({
112
+ ...question,
113
+ instructions: redact(question.instructions, this.#needles),
114
+ })),
115
+ };
116
+ observer?.({
117
+ type: 'request',
118
+ body: redact(JSON.stringify(observedPayload), this.#needles),
119
+ });
120
+ // Load the SDK only when an AI decision runs, matching Jev's lazy provider dependency.
121
+ const sdk = await import('openai');
122
+ let data;
123
+ let status;
124
+ try {
125
+ const client = this.#client ??= new sdk.default({
126
+ apiKey: this.#apiKey,
127
+ baseURL: this.#baseUrl,
128
+ timeout: TIMEOUT_MS,
129
+ maxRetries: 2,
130
+ fetchOptions: { redirect: 'manual' },
131
+ logLevel: 'off',
132
+ defaultHeaders: { Authorization: `Bearer ${this.#apiKey}` },
133
+ });
134
+ const result = await client.decisions.create(payload).withResponse();
135
+ data = result.data;
136
+ status = result.response.status;
137
+ }
138
+ catch (error) {
139
+ if (error instanceof sdk.APIError && error.status !== undefined) {
140
+ const body = error.error === undefined ? error.message : JSON.stringify(error.error) ?? error.message;
141
+ observer?.({ type: 'response', status: error.status, body: redact(body, this.#needles) });
142
+ throw new Error(`Decision request failed with HTTP ${error.status}`);
143
+ }
144
+ if (error instanceof sdk.APIConnectionTimeoutError) {
145
+ throw new Error(`Decision request timed out after ${TIMEOUT_MS} ms`);
146
+ }
147
+ if (error instanceof sdk.APIConnectionError) {
148
+ throw new Error('Decision request failed: network error');
149
+ }
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ throw new Error(`Decision request failed: ${redact(message, this.#needles)}`);
152
+ }
153
+ observer?.({
154
+ type: 'response',
155
+ status,
156
+ body: redact(JSON.stringify(data) ?? '', this.#needles),
157
+ });
158
+ return readDecision(data);
159
+ }
160
+ }
@@ -7,10 +7,15 @@ export interface RuleTestWorkerRequest {
7
7
  readonly files: RuleTestCase['files'];
8
8
  readonly ai: boolean;
9
9
  readonly dependencies?: PreparedRuleDependencies;
10
+ readonly includeDisabled?: boolean;
11
+ }
12
+ export interface RuleTestWorkerOutcome {
13
+ readonly actual: RuleTestCase['expected'];
14
+ readonly aiScores?: readonly number[];
10
15
  }
11
16
  export type RuleTestWorkerMessage = {
12
17
  readonly id: number;
13
- readonly actual: RuleTestCase['expected'];
18
+ readonly outcome: RuleTestWorkerOutcome;
14
19
  } | {
15
20
  readonly id: number;
16
21
  readonly error: string;
@@ -12,7 +12,7 @@ function fixturePath(root, filepath) {
12
12
  }
13
13
  return target;
14
14
  }
15
- async function runCase({ root, ruleFile, files, ai, dependencies }) {
15
+ async function runCase({ root, ruleFile, files, ai, dependencies, includeDisabled }) {
16
16
  for (const [filepath, source] of Object.entries(files)) {
17
17
  const target = fixturePath(root, filepath);
18
18
  await mkdir(dirname(target), { recursive: true });
@@ -21,6 +21,8 @@ async function runCase({ root, ruleFile, files, ai, dependencies }) {
21
21
  const rules = await createRuleLoader(dependencies === undefined ? {} : { preparedDependencies: dependencies }).load({ root, explicit: ruleFile, invocationCwd: root });
22
22
  if (rules.length !== 1)
23
23
  throw new Error('Expected exactly one freshly loaded rule');
24
+ if (includeDisabled === true)
25
+ rules[0].rule.enabled = true;
24
26
  const reports = await executeRules(rules, {
25
27
  project: createProject(root, { excludeFiles: [ruleFile] }),
26
28
  isTarget: () => true,
@@ -32,7 +34,15 @@ async function runCase({ root, ruleFile, files, ai, dependencies }) {
32
34
  const report = reports[0];
33
35
  if (reports.length !== 1 || report === undefined)
34
36
  throw new Error('Expected exactly one rule report');
35
- return report.status;
37
+ let aiScores;
38
+ if (ai) {
39
+ for (const evaluation of report.evaluations) {
40
+ const score = evaluation.result.probability;
41
+ if (score !== undefined)
42
+ (aiScores ??= []).push(score);
43
+ }
44
+ }
45
+ return { actual: report.status, ...(aiScores === undefined ? {} : { aiScores }) };
36
46
  }
37
47
  const port = parentPort;
38
48
  if (port === null)
@@ -40,7 +50,7 @@ if (port === null)
40
50
  port.start();
41
51
  port.ref();
42
52
  port.on('message', (request) => {
43
- void runCase(request).then(actual => port.postMessage({ id: request.id, actual }), error => port.postMessage({
53
+ void runCase(request).then(outcome => port.postMessage({ id: request.id, outcome }), error => port.postMessage({
44
54
  id: request.id,
45
55
  error: error instanceof Error ? error.message : String(error),
46
56
  }));
@@ -1,3 +1,3 @@
1
1
  import type { LoadedRule } from '../../app/ports.js';
2
2
  import type { RuleTestResult } from '../../app/rule-tests.js';
3
- export declare function runRuleTests(rules: readonly LoadedRule[], onResult?: (result: RuleTestResult, lastInRule: boolean) => void, timeoutMs?: number, concurrency?: number, includeAI?: boolean): Promise<readonly RuleTestResult[]>;
3
+ export declare function runRuleTests(rules: readonly LoadedRule[], onResult?: (result: RuleTestResult, lastInRule: boolean) => void, timeoutMs?: number, concurrency?: number, includeAI?: boolean, includeDisabled?: boolean): Promise<readonly RuleTestResult[]>;