@alex-dbs/coderuler 1.4.0 → 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 +3 -1
- package/dist/adapter/cli/commands/check.js +21 -10
- package/dist/adapter/cli/program.js +2 -2
- package/dist/adapter/cli/reports.js +1 -1
- package/dist/adapter/cli/services.d.ts +4 -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 +1 -0
- package/dist/adapter/filesystem/rule-test-worker.js +3 -1
- package/dist/adapter/filesystem/rule-tests.d.ts +1 -1
- package/dist/adapter/filesystem/rule-tests.js +58 -20
- 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/execute.js +9 -9
- package/dist/app/ports.d.ts +6 -1
- package/dist/app/rule.d.ts +12 -10
- 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 +2 -1
- package/skills/coderuler-new-rule/SKILL.md +7 -3
- package/skills/coderuler-new-rule/reference.md +27 -13
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`.
|
|
@@ -158,7 +160,7 @@ A scope is a file or selected part of a file. CodeRuler validates selected rules
|
|
|
158
160
|
The `settings` field sets AI options for one rule, such as its model, decision threshold, and input or output token prices.
|
|
159
161
|
|
|
160
162
|
> [!TIP]
|
|
161
|
-
> Browse the [`.coderuler` folder](.coderuler) for rule examples.
|
|
163
|
+
> Browse the [`.coderuler.examples` folder](.coderuler.examples) for rule examples.
|
|
162
164
|
|
|
163
165
|
## Commands
|
|
164
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
|
|
28
|
-
? await services.
|
|
29
|
-
:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
44
|
+
if (changes !== undefined) {
|
|
40
45
|
const ruleFiles = new Set(prepared.rules.map(entry => relative(area.root, resolve(entry.ruleFile)).split(sep).join('/')));
|
|
41
|
-
|
|
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
|
-
...(
|
|
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 };
|
|
@@ -128,7 +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
|
|
131
|
+
.option('--ai-scores', 'show AI scores for aiTests')
|
|
132
132
|
.option('--failures-only', 'show only failed test cases')
|
|
133
133
|
.addOption(new Option('--timeout <duration>', 'per-test timeout (ms, s, or m)')
|
|
134
134
|
.argParser(parseTimeout)
|
|
@@ -143,7 +143,7 @@ export function createProgram(services) {
|
|
|
143
143
|
.command('check')
|
|
144
144
|
.description('Evaluate every selected rule against the working tree')
|
|
145
145
|
.argument('[path]', 'target file or folder that narrows selection', '.')
|
|
146
|
-
.option('--rules <rule>', 'rule name, module path, or
|
|
146
|
+
.option('--rules <rule>', 'rule name, module path, directory, or Git repository URL[#revision]')
|
|
147
147
|
.option('--tags <tags...>', 'run rules matching any listed tag', [])
|
|
148
148
|
.option('--exclude-tags <tags...>', 'exclude rules matching any listed tag', [])
|
|
149
149
|
.option('--json', 'print violations as {durationMs, reports} JSON on stdout')
|
|
@@ -45,7 +45,7 @@ export function renderRuleTestResult(result, redact, color, lastInRule, showAISc
|
|
|
45
45
|
? ''
|
|
46
46
|
: safe(` (expected ${result.expected}, actual ${result.actual})`);
|
|
47
47
|
const aiScores = showAIScores && result.aiScores !== undefined && result.aiScores.length > 0
|
|
48
|
-
? ` (${result.aiScores.length === 1 ? '
|
|
48
|
+
? ` (${result.aiScores.length === 1 ? 'AI score' : 'AI scores'}: ${result.aiScores.join(', ')})`
|
|
49
49
|
: '';
|
|
50
50
|
const duration = paint(safe(` (${formatDuration(result.durationMs)})`), 'dim', color);
|
|
51
51
|
return `${branch}${label} ${safe(result.name)}${detail}${aiScores}${duration}\n`;
|
|
@@ -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';
|
|
@@ -71,8 +72,9 @@ export interface ExecutionServices extends RuleServices {
|
|
|
71
72
|
readonly decisionResolver: (thresholdOverride?: number, pricingOverrides?: Partial<TokenPricing>) => RunContext['resolveDecision'];
|
|
72
73
|
}
|
|
73
74
|
export interface CheckServices extends ExecutionServices {
|
|
74
|
-
readonly
|
|
75
|
+
readonly collectChanges: (root: string, base: string) => Promise<DiffChanges>;
|
|
75
76
|
readonly checkoutPullRequest: (selector: string, baseBranch?: string) => Promise<PullRequestCheckout>;
|
|
77
|
+
readonly checkoutRuleRepository: (selector: string) => Promise<RuleRepositoryCheckout | undefined>;
|
|
76
78
|
readonly commentOnPullRequest: (root: string, comments: readonly ViolationComment[], pullRequest?: PullRequest) => Promise<void>;
|
|
77
79
|
}
|
|
78
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,6 +7,7 @@ export interface RuleTestWorkerRequest {
|
|
|
7
7
|
readonly files: RuleTestCase['files'];
|
|
8
8
|
readonly ai: boolean;
|
|
9
9
|
readonly dependencies?: PreparedRuleDependencies;
|
|
10
|
+
readonly includeDisabled?: boolean;
|
|
10
11
|
}
|
|
11
12
|
export interface RuleTestWorkerOutcome {
|
|
12
13
|
readonly actual: RuleTestCase['expected'];
|
|
@@ -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,
|
|
@@ -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[]>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { Worker } from 'node:worker_threads';
|
|
6
6
|
import { isRecord, validateJsonValue } from '../../app/validate.js';
|
|
7
|
-
import { getRuleDependencies } from './rule-resolution.js';
|
|
7
|
+
import { getRuleDependencies, registerRuleDependencies } from './rule-resolution.js';
|
|
8
8
|
// A JavaScript entry lets inherited tsx hooks resolve the source worker's .js import.
|
|
9
9
|
const WORKER_MODULE = new URL('./rule-test-worker.js', import.meta.url);
|
|
10
10
|
const WORKER_ENTRY = new URL(`data:text/javascript,${encodeURIComponent(`import ${JSON.stringify(WORKER_MODULE.href)};`)}`);
|
|
@@ -24,6 +24,35 @@ function validateCase(value, label) {
|
|
|
24
24
|
}
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
|
+
async function loadExternalTests(ruleFile, testFile) {
|
|
28
|
+
try {
|
|
29
|
+
await lstat(testFile);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT')
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const dependencies = getRuleDependencies(ruleFile);
|
|
38
|
+
if (dependencies !== undefined)
|
|
39
|
+
registerRuleDependencies(testFile, dependencies);
|
|
40
|
+
// The companion path comes from the selected rule filename.
|
|
41
|
+
return await import(pathToFileURL(testFile).href);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`Rule test module ${testFile} failed to import: ${detail}`, { cause: error });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function addTestGroup(groups, values, sourceLabel, exportName, ai) {
|
|
49
|
+
if (values === undefined)
|
|
50
|
+
return;
|
|
51
|
+
if (!Array.isArray(values)) {
|
|
52
|
+
throw new Error(`${sourceLabel} ${exportName}: expected an array of test cases`);
|
|
53
|
+
}
|
|
54
|
+
groups.push({ values, label: `${sourceLabel} ${exportName}`, ai });
|
|
55
|
+
}
|
|
27
56
|
class RuleTestWorkerSession {
|
|
28
57
|
worker = new Worker(WORKER_ENTRY);
|
|
29
58
|
nextId = 0;
|
|
@@ -114,7 +143,7 @@ async function stopSession(session) {
|
|
|
114
143
|
if (session.current === current)
|
|
115
144
|
session.current = undefined;
|
|
116
145
|
}
|
|
117
|
-
async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
146
|
+
async function runCase(entry, example, ai, timeoutMs, session, onFailure, includeDisabled) {
|
|
118
147
|
const started = performance.now();
|
|
119
148
|
const deadline = started + timeoutMs;
|
|
120
149
|
let failure;
|
|
@@ -141,6 +170,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
141
170
|
const dependencies = getRuleDependencies(entry.ruleFile);
|
|
142
171
|
const outcome = await active.run({
|
|
143
172
|
root, ruleFile: entry.ruleFile, files: example.files, ai,
|
|
173
|
+
...(includeDisabled ? { includeDisabled: true } : {}),
|
|
144
174
|
...(dependencies === undefined ? {} : { dependencies }),
|
|
145
175
|
}, deadline, failCase);
|
|
146
176
|
if (typeof outcome === 'string')
|
|
@@ -180,7 +210,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
180
210
|
throw cause;
|
|
181
211
|
}
|
|
182
212
|
}
|
|
183
|
-
export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false) {
|
|
213
|
+
export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false, includeDisabled = false) {
|
|
184
214
|
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
185
215
|
throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`);
|
|
186
216
|
}
|
|
@@ -204,24 +234,28 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
|
|
|
204
234
|
for (const rule of rules) {
|
|
205
235
|
if (failureObserved)
|
|
206
236
|
throw failure;
|
|
207
|
-
if (!rule.rule.enabled)
|
|
237
|
+
if (!includeDisabled && !rule.rule.enabled)
|
|
208
238
|
continue;
|
|
209
239
|
// Discovery selects the rule module path at runtime.
|
|
210
|
-
const
|
|
240
|
+
const ruleModule = await import(pathToFileURL(rule.ruleFile).href);
|
|
241
|
+
const testFile = rule.ruleFile.replace(/\.rule\.(ts|mts|js|mjs)$/, '.test.$1');
|
|
242
|
+
const externalModule = await loadExternalTests(rule.ruleFile, testFile);
|
|
243
|
+
const { tests, aiTests } = ruleModule;
|
|
211
244
|
if (failureObserved)
|
|
212
245
|
throw failure;
|
|
213
|
-
if (!includeAI && aiTests !== undefined)
|
|
246
|
+
if (!includeAI && (aiTests !== undefined || externalModule?.aiTests !== undefined)) {
|
|
214
247
|
hasExcludedAITests = true;
|
|
215
|
-
const selectedAI = includeAI ? aiTests : undefined;
|
|
216
|
-
const label = `Rule "${rule.name}"`;
|
|
217
|
-
if (tests !== undefined && !Array.isArray(tests)) {
|
|
218
|
-
throw new Error(`${label} tests: expected an array of test cases`);
|
|
219
248
|
}
|
|
220
|
-
|
|
221
|
-
|
|
249
|
+
const label = `Rule "${rule.name}"`;
|
|
250
|
+
const externalLabel = `${label} test module ${testFile}`;
|
|
251
|
+
const groups = [];
|
|
252
|
+
addTestGroup(groups, tests, label, 'tests', false);
|
|
253
|
+
addTestGroup(groups, externalModule?.tests, externalLabel, 'tests', false);
|
|
254
|
+
if (includeAI) {
|
|
255
|
+
addTestGroup(groups, aiTests, label, 'aiTests', true);
|
|
256
|
+
addTestGroup(groups, externalModule?.aiTests, externalLabel, 'aiTests', true);
|
|
222
257
|
}
|
|
223
|
-
const
|
|
224
|
-
const count = regularCount + (selectedAI?.length ?? 0);
|
|
258
|
+
const count = groups.reduce((total, group) => total + group.values.length, 0);
|
|
225
259
|
const completed = new Array(count);
|
|
226
260
|
let nextIndex = 0;
|
|
227
261
|
let nextResult = 0;
|
|
@@ -231,11 +265,15 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
|
|
|
231
265
|
if (index >= count)
|
|
232
266
|
return;
|
|
233
267
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
let groupIndex = 0;
|
|
269
|
+
let caseIndex = index;
|
|
270
|
+
while (caseIndex >= groups[groupIndex].values.length) {
|
|
271
|
+
caseIndex -= groups[groupIndex].values.length;
|
|
272
|
+
groupIndex += 1;
|
|
273
|
+
}
|
|
274
|
+
const group = groups[groupIndex];
|
|
275
|
+
const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`);
|
|
276
|
+
completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled);
|
|
239
277
|
if (failureObserved)
|
|
240
278
|
return;
|
|
241
279
|
while (completed[nextResult] !== undefined) {
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { DiffChanges } from '../../app/ports.js';
|
|
2
|
+
export declare function collectChanges(root: string, base: string, env?: Readonly<Record<string, string | undefined>>): Promise<DiffChanges>;
|