@alex-dbs/coderuler 0.0.0-stage → 1.3.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.
Files changed (121) hide show
  1. package/LICENSE +175 -0
  2. package/README.md +218 -2
  3. package/dist/adapter/cli/commands/check.d.ts +3 -0
  4. package/dist/adapter/cli/commands/check.js +93 -0
  5. package/dist/adapter/cli/commands/deps.d.ts +3 -0
  6. package/dist/adapter/cli/commands/deps.js +4 -0
  7. package/dist/adapter/cli/commands/inspect.d.ts +3 -0
  8. package/dist/adapter/cli/commands/inspect.js +59 -0
  9. package/dist/adapter/cli/commands/rules.d.ts +10 -0
  10. package/dist/adapter/cli/commands/rules.js +17 -0
  11. package/dist/adapter/cli/commands/skill.d.ts +4 -0
  12. package/dist/adapter/cli/commands/skill.js +7 -0
  13. package/dist/adapter/cli/commands/test.d.ts +3 -0
  14. package/dist/adapter/cli/commands/test.js +78 -0
  15. package/dist/adapter/cli/commands/update.d.ts +3 -0
  16. package/dist/adapter/cli/commands/update.js +7 -0
  17. package/dist/adapter/cli/commands/validate.d.ts +3 -0
  18. package/dist/adapter/cli/commands/validate.js +12 -0
  19. package/dist/adapter/cli/exit.d.ts +7 -0
  20. package/dist/adapter/cli/exit.js +10 -0
  21. package/dist/adapter/cli/inspect-json.d.ts +9 -0
  22. package/dist/adapter/cli/inspect-json.js +160 -0
  23. package/dist/adapter/cli/inspect-usage.d.ts +9 -0
  24. package/dist/adapter/cli/inspect-usage.js +14 -0
  25. package/dist/adapter/cli/program.d.ts +4 -0
  26. package/dist/adapter/cli/program.js +216 -0
  27. package/dist/adapter/cli/progress.d.ts +22 -0
  28. package/dist/adapter/cli/progress.js +79 -0
  29. package/dist/adapter/cli/reports.d.ts +47 -0
  30. package/dist/adapter/cli/reports.js +735 -0
  31. package/dist/adapter/cli/self-update.d.ts +1 -0
  32. package/dist/adapter/cli/self-update.js +89 -0
  33. package/dist/adapter/cli/services.d.ts +101 -0
  34. package/dist/adapter/cli/services.js +1 -0
  35. package/dist/adapter/cli/text.d.ts +5 -0
  36. package/dist/adapter/cli/text.js +36 -0
  37. package/dist/adapter/cli/version.d.ts +1 -0
  38. package/dist/adapter/cli/version.js +3 -0
  39. package/dist/adapter/decision/jev.d.ts +11 -0
  40. package/dist/adapter/decision/jev.js +138 -0
  41. package/dist/adapter/filesystem/dependencies.d.ts +26 -0
  42. package/dist/adapter/filesystem/dependencies.js +577 -0
  43. package/dist/adapter/filesystem/dependency-installation.d.ts +11 -0
  44. package/dist/adapter/filesystem/dependency-installation.js +373 -0
  45. package/dist/adapter/filesystem/project.d.ts +14 -0
  46. package/dist/adapter/filesystem/project.js +387 -0
  47. package/dist/adapter/filesystem/rule-resolution.d.ts +4 -0
  48. package/dist/adapter/filesystem/rule-resolution.js +516 -0
  49. package/dist/adapter/filesystem/rule-test-worker.d.ts +17 -0
  50. package/dist/adapter/filesystem/rule-test-worker.js +47 -0
  51. package/dist/adapter/filesystem/rule-tests.d.ts +3 -0
  52. package/dist/adapter/filesystem/rule-tests.js +264 -0
  53. package/dist/adapter/filesystem/rules.d.ts +13 -0
  54. package/dist/adapter/filesystem/rules.js +242 -0
  55. package/dist/adapter/git/changes.d.ts +1 -0
  56. package/dist/adapter/git/changes.js +54 -0
  57. package/dist/adapter/git/environment.d.ts +1 -0
  58. package/dist/adapter/git/environment.js +10 -0
  59. package/dist/adapter/github/api.d.ts +5 -0
  60. package/dist/adapter/github/api.js +40 -0
  61. package/dist/adapter/github/comments.d.ts +3 -0
  62. package/dist/adapter/github/comments.js +235 -0
  63. package/dist/adapter/github/pull-request.d.ts +16 -0
  64. package/dist/adapter/github/pull-request.js +139 -0
  65. package/dist/adapter/scope/astgrep-languages.d.ts +5 -0
  66. package/dist/adapter/scope/astgrep-languages.js +119 -0
  67. package/dist/adapter/scope/astgrep.d.ts +14 -0
  68. package/dist/adapter/scope/astgrep.js +183 -0
  69. package/dist/adapter/scope/csv.d.ts +4 -0
  70. package/dist/adapter/scope/csv.js +158 -0
  71. package/dist/adapter/scope/file.d.ts +2 -0
  72. package/dist/adapter/scope/file.js +4 -0
  73. package/dist/adapter/scope/json.d.ts +7 -0
  74. package/dist/adapter/scope/json.js +132 -0
  75. package/dist/adapter/scope/lines.d.ts +10 -0
  76. package/dist/adapter/scope/lines.js +42 -0
  77. package/dist/adapter/scope/protobuf-options.d.ts +29 -0
  78. package/dist/adapter/scope/protobuf-options.js +64 -0
  79. package/dist/adapter/scope/protobuf-parser.d.ts +30 -0
  80. package/dist/adapter/scope/protobuf-parser.js +772 -0
  81. package/dist/adapter/scope/protobuf.d.ts +5 -0
  82. package/dist/adapter/scope/protobuf.js +53 -0
  83. package/dist/adapter/scope/structured.d.ts +10 -0
  84. package/dist/adapter/scope/structured.js +84 -0
  85. package/dist/adapter/scope/yaml.d.ts +7 -0
  86. package/dist/adapter/scope/yaml.js +224 -0
  87. package/dist/adapter/skills/cli.d.ts +10 -0
  88. package/dist/adapter/skills/cli.js +77 -0
  89. package/dist/app/concurrency.d.ts +1 -0
  90. package/dist/app/concurrency.js +44 -0
  91. package/dist/app/evaluators.d.ts +14 -0
  92. package/dist/app/evaluators.js +175 -0
  93. package/dist/app/execute.d.ts +2 -0
  94. package/dist/app/execute.js +427 -0
  95. package/dist/app/jsonpath.d.ts +1 -0
  96. package/dist/app/jsonpath.js +8 -0
  97. package/dist/app/ports.d.ts +165 -0
  98. package/dist/app/ports.js +1 -0
  99. package/dist/app/rule-tests.d.ts +20 -0
  100. package/dist/app/rule-tests.js +11 -0
  101. package/dist/app/rule.d.ts +121 -0
  102. package/dist/app/rule.js +24 -0
  103. package/dist/app/selection.d.ts +2 -0
  104. package/dist/app/selection.js +23 -0
  105. package/dist/app/usage.d.ts +2 -0
  106. package/dist/app/usage.js +13 -0
  107. package/dist/app/validate.d.ts +11 -0
  108. package/dist/app/validate.js +415 -0
  109. package/dist/app/violation-comments.d.ts +10 -0
  110. package/dist/app/violation-comments.js +1 -0
  111. package/dist/cli.d.ts +2 -0
  112. package/dist/cli.js +3 -0
  113. package/dist/index.d.ts +22 -0
  114. package/dist/index.js +11 -0
  115. package/dist/infra/runtime.d.ts +3 -0
  116. package/dist/infra/runtime.js +168 -0
  117. package/dist/infra/settings.d.ts +5 -0
  118. package/dist/infra/settings.js +91 -0
  119. package/package.json +86 -4
  120. package/skills/coderuler-new-rule/SKILL.md +141 -0
  121. package/skills/coderuler-new-rule/reference.md +447 -0
@@ -0,0 +1,175 @@
1
+ import { isRecord, validateAiUsage, validateJsonValue } from './validate.js';
2
+ const QUESTION = 'Is the rule respected?';
3
+ function fail(detail) {
4
+ throw new Error(detail);
5
+ }
6
+ export function pass() {
7
+ return { status: 'pass' };
8
+ }
9
+ export function violation(message, locations) {
10
+ return { status: 'violation', message, ...(locations === undefined ? {} : { locations }) };
11
+ }
12
+ function assertExampleList(value, label) {
13
+ if (value === undefined)
14
+ return;
15
+ if (!Array.isArray(value))
16
+ fail(`${label} must be an array of non-empty strings`);
17
+ for (let index = 0; index < value.length; index += 1) {
18
+ if (!Object.hasOwn(value, index))
19
+ fail(`${label} must be an array of non-empty strings`);
20
+ const entry = value[index];
21
+ if (typeof entry !== 'string' || entry === '')
22
+ fail(`${label} must be an array of non-empty strings`);
23
+ }
24
+ }
25
+ function assertAiOptions(options) {
26
+ if (typeof options.rule !== 'string' || options.rule === '') {
27
+ fail('ai rule must be a non-empty string');
28
+ }
29
+ const examples = options.examples;
30
+ if (examples !== undefined) {
31
+ if (!isRecord(examples))
32
+ fail('ai examples must be an object');
33
+ assertExampleList(examples['good'], 'ai examples.good');
34
+ assertExampleList(examples['bad'], 'ai examples.bad');
35
+ }
36
+ const message = options.violationMessage;
37
+ if (message !== undefined && (typeof message !== 'string' || message === '')) {
38
+ fail('ai violationMessage must be a non-empty string');
39
+ }
40
+ const input = options.input;
41
+ if (input !== undefined && typeof input !== 'function')
42
+ fail('ai input must be a function');
43
+ }
44
+ export function ai(options) {
45
+ assertAiOptions(options);
46
+ return (_subject, context) => context.ai(options);
47
+ }
48
+ function projectScope(scope) {
49
+ const captures = scope.captures;
50
+ if (scope.data === undefined) {
51
+ return captures === undefined
52
+ ? { id: scope.id, source: scope.source }
53
+ : { id: scope.id, source: scope.source, captures };
54
+ }
55
+ const data = scope.data;
56
+ return captures === undefined
57
+ ? { id: scope.id, data }
58
+ : { id: scope.id, data, captures };
59
+ }
60
+ function projectState(subject) {
61
+ if (subject.kind === 'group') {
62
+ const files = [];
63
+ const byFilepath = new Map();
64
+ for (const member of subject.members) {
65
+ let file = byFilepath.get(member.filepath);
66
+ if (file === undefined) {
67
+ file = { filepath: member.filepath, scopes: [] };
68
+ byFilepath.set(member.filepath, file);
69
+ files.push(file);
70
+ }
71
+ file.scopes.push(projectScope(member));
72
+ }
73
+ return { files };
74
+ }
75
+ return { filepath: subject.filepath, scope: projectScope(subject) };
76
+ }
77
+ function instructionsOf(options) {
78
+ const good = options.examples?.good ?? [];
79
+ const bad = options.examples?.bad ?? [];
80
+ if (good.length === 0 && bad.length === 0)
81
+ return { rule: options.rule, question: QUESTION };
82
+ return { rule: options.rule, question: QUESTION, examples: { good, bad } };
83
+ }
84
+ export async function evaluateAi(subject, options, context, runtime, observer) {
85
+ assertAiOptions(options);
86
+ const state = options.input === undefined
87
+ ? projectState(subject)
88
+ : validateJsonValue(await options.input(subject, context), 'ai input');
89
+ let decision;
90
+ let usage;
91
+ try {
92
+ decision = await runtime.backend.decide({ state, instructions: instructionsOf(options) }, observer === undefined ? undefined : exchange => {
93
+ if (exchange.type !== 'usage')
94
+ observer(exchange);
95
+ });
96
+ const tokens = decision.usage;
97
+ usage = tokens === undefined
98
+ ? { calls: 1, unreportedCalls: 1, inputTokens: 0, outputTokens: 0, inputCost: 0, outputCost: 0 }
99
+ : validateAiUsage({
100
+ calls: 1,
101
+ unreportedCalls: 0,
102
+ inputTokens: tokens.inputTokens,
103
+ outputTokens: tokens.outputTokens,
104
+ inputCost: tokenCost(tokens.inputTokens, runtime.pricing.inputCostPerMillion),
105
+ outputCost: tokenCost(tokens.outputTokens, runtime.pricing.outputCostPerMillion),
106
+ }, 'Decision outcome');
107
+ }
108
+ catch (error) {
109
+ observer?.({
110
+ type: 'usage',
111
+ usage: { calls: 1, unreportedCalls: 1, inputTokens: 0, outputTokens: 0, inputCost: 0, outputCost: 0 },
112
+ });
113
+ throw error;
114
+ }
115
+ observer?.({ type: 'usage', usage });
116
+ if (decision.probability > runtime.threshold) {
117
+ return {
118
+ status: 'pass', probability: decision.probability, threshold: runtime.threshold, model: decision.model, usage,
119
+ };
120
+ }
121
+ return {
122
+ status: 'violation',
123
+ ...(options.violationMessage === undefined ? {} : { message: options.violationMessage }),
124
+ probability: decision.probability,
125
+ threshold: runtime.threshold,
126
+ model: decision.model,
127
+ usage,
128
+ };
129
+ }
130
+ function tokenCost(tokens, price) {
131
+ const product = tokens * price;
132
+ return Number.isFinite(product) ? product / 1_000_000 : (tokens / 1_000_000) * price;
133
+ }
134
+ function regexValue(scope) {
135
+ const { data, source } = scope;
136
+ if (data === undefined || (typeof data === 'object' && data !== null))
137
+ return source;
138
+ return String(data);
139
+ }
140
+ export function regex(options) {
141
+ const { pattern: callerPattern, violationMessage, input } = options;
142
+ if (!(callerPattern instanceof RegExp))
143
+ fail('regex pattern must be a RegExp');
144
+ if (violationMessage !== undefined && (typeof violationMessage !== 'string' || violationMessage === '')) {
145
+ fail('regex violationMessage must be a non-empty string');
146
+ }
147
+ if (input !== undefined && typeof input !== 'function')
148
+ fail('regex input must be a function');
149
+ const pattern = new RegExp(callerPattern.source, callerPattern.flags);
150
+ return async (subject, context) => {
151
+ const members = subject.kind === 'group' ? subject.members : [subject];
152
+ const evaluations = [];
153
+ for (const member of members) {
154
+ const value = input === undefined ? regexValue(member) : await input(member, context);
155
+ pattern.lastIndex = 0;
156
+ evaluations.push({
157
+ filepath: member.filepath,
158
+ scopeId: member.id,
159
+ input: value,
160
+ matched: pattern.test(value),
161
+ });
162
+ }
163
+ context.trace('regex', { pattern: pattern.source, flags: pattern.flags, evaluations });
164
+ const locations = [];
165
+ for (const { matched, filepath, scopeId } of evaluations) {
166
+ if (!matched)
167
+ locations.push({ filepath, scopeId });
168
+ }
169
+ if (locations.length === 0)
170
+ return pass();
171
+ return violationMessage === undefined
172
+ ? { status: 'violation', locations }
173
+ : violation(violationMessage, locations);
174
+ };
175
+ }
@@ -0,0 +1,2 @@
1
+ import type { LoadedRule, RuleReport, RunContext } from './ports.js';
2
+ export declare function executeRules(rules: readonly LoadedRule[], context: RunContext): Promise<readonly RuleReport[]>;
@@ -0,0 +1,427 @@
1
+ import { matchesGlob } from 'node:path';
2
+ import { mapConcurrent } from './concurrency.js';
3
+ import { evaluateAi } from './evaluators.js';
4
+ import { wholeFileScope } from './selection.js';
5
+ import { addUsage } from './usage.js';
6
+ import { validateCheckResult, validatePredicateResult, validateScopeMatch, validateScopeSubjects, } from './validate.js';
7
+ function isEligibleRelativePath(filepath) {
8
+ if (filepath === '' || filepath.startsWith('/'))
9
+ return false;
10
+ const segments = filepath.split('/');
11
+ const last = segments.length - 1;
12
+ for (let index = 0; index < last; index += 1) {
13
+ const segment = segments[index];
14
+ if (segment === '' || segment === '.' || segment === '..')
15
+ return false;
16
+ }
17
+ const name = segments[last];
18
+ return name !== undefined && name !== '' && name !== '.' && name !== '..';
19
+ }
20
+ function folderPath(filepath, level) {
21
+ const segments = filepath.split('/');
22
+ segments.pop();
23
+ const keep = segments.length + level;
24
+ if (keep <= 0)
25
+ return '.';
26
+ return segments.slice(0, keep).join('/');
27
+ }
28
+ export async function executeRules(rules, context) {
29
+ let completedSelections = 0;
30
+ let completedEvaluations = 0;
31
+ const emit = (event) => {
32
+ context.emit?.(event);
33
+ };
34
+ const changedFiles = context.changedFiles;
35
+ const changedPaths = changedFiles === undefined
36
+ ? undefined
37
+ : [...changedFiles].filter(filepath => isEligibleRelativePath(filepath) && context.isTarget(filepath));
38
+ const traceFor = (name, evaluationId) => (message, details) => {
39
+ emit({
40
+ type: 'trace',
41
+ name,
42
+ message,
43
+ ...(evaluationId === undefined ? {} : { evaluationId }),
44
+ ...(details === undefined ? {} : { details }),
45
+ });
46
+ };
47
+ const projectContextFor = (name) => ({
48
+ project: context.project,
49
+ trace: traceFor(name),
50
+ });
51
+ const fail = (name, stage, error, evaluationId) => {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ emit({
54
+ type: 'error',
55
+ stage,
56
+ message,
57
+ ...(name === undefined ? {} : { name }),
58
+ ...(evaluationId === undefined ? {} : { evaluationId }),
59
+ });
60
+ return error instanceof Error ? error : new Error(message);
61
+ };
62
+ const statCache = new Map();
63
+ const assertEligible = async (name, subjects) => {
64
+ for (const subject of subjects) {
65
+ const { filepath } = subject;
66
+ if (!isEligibleRelativePath(filepath)) {
67
+ throw new Error(`Rule "${name}" selected an ineligible filepath "${filepath}"`);
68
+ }
69
+ if (!context.isTarget(filepath)) {
70
+ throw new Error(`Rule "${name}" selected "${filepath}" outside the CLI target`);
71
+ }
72
+ let pending = statCache.get(filepath);
73
+ if (pending === undefined) {
74
+ pending = context.project.stat(filepath);
75
+ statCache.set(filepath, pending);
76
+ }
77
+ const info = await pending;
78
+ if (info?.kind !== 'file') {
79
+ throw new Error(`Rule "${name}" selected a path that is not a regular file: "${filepath}"`);
80
+ }
81
+ }
82
+ };
83
+ const selectFromRule = async (name, selection, evaluationId, changedOnly = false) => {
84
+ const exclude = selection.exclude ?? [];
85
+ const candidates = await context.project.glob(selection.files);
86
+ const chosen = [];
87
+ for (const filepath of candidates) {
88
+ const excluded = exclude.some(pattern => matchesGlob(filepath, pattern));
89
+ const targeted = context.isTarget(filepath);
90
+ const changed = !changedOnly || changedFiles === undefined || changedFiles.has(filepath);
91
+ const selected = !excluded && targeted && changed;
92
+ const reason = excluded ? 'excluded' : !targeted ? 'outside target' : changed ? undefined : 'unchanged';
93
+ emit({
94
+ type: 'file',
95
+ name,
96
+ filepath,
97
+ selected,
98
+ ...(evaluationId === undefined ? {} : { evaluationId }),
99
+ ...(reason === undefined ? {} : { reason }),
100
+ });
101
+ if (selected)
102
+ chosen.push(filepath);
103
+ }
104
+ const scope = selection.scope;
105
+ const collected = await mapConcurrent(chosen, context.concurrency, async (filepath) => {
106
+ const source = await context.project.readText(filepath);
107
+ const matches = scope === undefined
108
+ ? wholeFileScope({ filepath, source })
109
+ : (await scope({ filepath, source }));
110
+ if (!Array.isArray(matches)) {
111
+ throw new Error(`Rule "${name}" scope selector must return an array of scopes`);
112
+ }
113
+ const subjects = [];
114
+ for (let index = 0; index < matches.length; index += 1) {
115
+ const label = `Rule "${name}" scope ${filepath}[${index}]`;
116
+ if (!Object.hasOwn(matches, index))
117
+ throw new Error(`${label}: missing scope entry`);
118
+ const valid = validateScopeMatch(matches[index], label);
119
+ subjects.push({ kind: 'scope', filepath, ...valid });
120
+ }
121
+ return subjects;
122
+ });
123
+ return collected.flat();
124
+ };
125
+ const selectionContextFor = (name, changedOnly) => ({
126
+ project: context.project,
127
+ trace: traceFor(name),
128
+ select: selection => selectFromRule(name, selection, undefined, changedOnly),
129
+ });
130
+ const runtimeOf = (plan) => {
131
+ let runtime = plan.runtime;
132
+ if (runtime === undefined) {
133
+ runtime = context.resolveDecision(plan.rule.settings);
134
+ plan.runtime = runtime;
135
+ }
136
+ return runtime;
137
+ };
138
+ const evaluateSubject = async (plan, subject, evaluationId, reportFailure) => {
139
+ plan.evaluationStartedAt ??= performance.now();
140
+ const { name, rule } = plan;
141
+ emit({ type: 'evaluation', evaluationId, name, subject });
142
+ const trace = traceFor(name, evaluationId);
143
+ let trackedUsage;
144
+ let pendingAi;
145
+ let accountingFailure;
146
+ const retainAccountingFailure = (error) => {
147
+ if (accountingFailure !== undefined)
148
+ return;
149
+ const failure = error instanceof Error ? error : new Error(String(error));
150
+ accountingFailure = { error: failure };
151
+ reportFailure(failure);
152
+ };
153
+ const throwIfAccountingFailed = () => {
154
+ if (accountingFailure !== undefined)
155
+ throw accountingFailure.error;
156
+ };
157
+ const observer = exchange => {
158
+ if (exchange.type === 'request') {
159
+ emit({ type: 'request', evaluationId, name, subject, body: exchange.body });
160
+ }
161
+ else if (exchange.type === 'response') {
162
+ emit({ type: 'response', evaluationId, name, subject, status: exchange.status, body: exchange.body });
163
+ }
164
+ else {
165
+ const next = exchange.usage;
166
+ try {
167
+ trackedUsage = addUsage(trackedUsage, next);
168
+ }
169
+ catch (error) {
170
+ retainAccountingFailure(error);
171
+ }
172
+ // Individual usage remains valid even when its aggregate cannot be represented.
173
+ try {
174
+ emit({ type: 'usage', evaluationId, name, subject, usage: next });
175
+ }
176
+ catch (error) {
177
+ retainAccountingFailure(error);
178
+ }
179
+ throwIfAccountingFailed();
180
+ }
181
+ };
182
+ const checkContext = {
183
+ project: context.project,
184
+ ruleName: name,
185
+ trace,
186
+ select: selection => selectFromRule(name, selection, evaluationId),
187
+ ai: options => {
188
+ const call = evaluateAi(subject, options, { project: context.project, ruleName: name, trace }, runtimeOf(plan), observer);
189
+ const pending = pendingAi ??= new Set();
190
+ pending.add(call);
191
+ // Observe both outcomes immediately; the check still receives the original promise.
192
+ void call.then(() => { pending.delete(call); }, () => { pending.delete(call); });
193
+ return call;
194
+ },
195
+ };
196
+ let result;
197
+ try {
198
+ try {
199
+ const raw = await rule.check.call(rule, subject, checkContext);
200
+ throwIfAccountingFailed();
201
+ const valid = validateCheckResult(raw, `Rule "${name}" check result`, subject);
202
+ const { status, message, model, probability, threshold, usage, locations } = valid;
203
+ // Snapshot declared fields before draining: custom checks still own their returned objects.
204
+ // Validate the snapshot too, since accessors may change values between reads.
205
+ result = validateCheckResult({
206
+ status,
207
+ ...(message === undefined ? {} : { message }),
208
+ ...(probability === undefined ? {} : { probability }),
209
+ ...(threshold === undefined ? {} : { threshold }),
210
+ ...(model === undefined ? {} : { model }),
211
+ ...(locations === undefined ? {} : { locations }),
212
+ ...(usage === undefined ? {} : { usage: {
213
+ calls: usage.calls,
214
+ unreportedCalls: usage.unreportedCalls,
215
+ inputTokens: usage.inputTokens,
216
+ outputTokens: usage.outputTokens,
217
+ inputCost: usage.inputCost,
218
+ outputCost: usage.outputCost,
219
+ } }),
220
+ }, `Rule "${name}" check result`);
221
+ }
222
+ catch (error) {
223
+ const cause = accountingFailure === undefined ? error : accountingFailure.error;
224
+ const failure = cause instanceof Error ? cause : new Error(String(cause));
225
+ reportFailure(failure);
226
+ throw failure;
227
+ }
228
+ finally {
229
+ // Promise.all may reject while sibling calls are still producing usage events.
230
+ while (pendingAi !== undefined && pendingAi.size > 0) {
231
+ await Promise.allSettled(pendingAi);
232
+ }
233
+ }
234
+ // A custom check may catch context.ai failures, but cannot discard accounting failures.
235
+ throwIfAccountingFailed();
236
+ }
237
+ catch (error) {
238
+ throw fail(name, 'check', error, evaluationId);
239
+ }
240
+ const normalized = trackedUsage === undefined ? result : { ...result, usage: trackedUsage };
241
+ emit({ type: 'result', evaluationId, name, subject, result: normalized });
242
+ context.emit?.({
243
+ type: 'progress', phase: 'evaluation', completed: ++completedEvaluations, total: tasks.length,
244
+ });
245
+ plan.evaluationFinishedAt = performance.now();
246
+ return normalized;
247
+ };
248
+ const plans = [];
249
+ context.emit?.({ type: 'progress', phase: 'selection', completed: 0, total: rules.length });
250
+ for (const entry of rules) {
251
+ const selectionStartedAt = performance.now();
252
+ const rule = entry.rule;
253
+ const { name, ruleFile } = entry;
254
+ emit({ type: 'rule', name, ruleFile });
255
+ const plan = {
256
+ name,
257
+ ruleFile,
258
+ rule,
259
+ subjects: [],
260
+ evaluationSubjects: [],
261
+ evaluations: [],
262
+ skipped: undefined,
263
+ runtime: undefined,
264
+ durationMs: 0,
265
+ evaluationStartedAt: undefined,
266
+ evaluationFinishedAt: 0,
267
+ };
268
+ plans.push(plan);
269
+ if (!rule.enabled) {
270
+ emit({ type: 'gate', name, enabled: false, applies: false, reason: 'disabled' });
271
+ plan.skipped = 'disabled';
272
+ context.emit?.({
273
+ type: 'progress', phase: 'selection', completed: ++completedSelections, total: rules.length,
274
+ });
275
+ plan.durationMs = performance.now() - selectionStartedAt;
276
+ continue;
277
+ }
278
+ let gate;
279
+ try {
280
+ gate = validatePredicateResult(await rule.when.call(rule, projectContextFor(name)), `Rule "${name}" when`);
281
+ }
282
+ catch (error) {
283
+ throw fail(name, 'when', error);
284
+ }
285
+ const applies = typeof gate === 'boolean' ? gate : gate.applies;
286
+ const gateReason = typeof gate === 'boolean' ? undefined : gate.reason;
287
+ if (!applies) {
288
+ emit({
289
+ type: 'gate',
290
+ name,
291
+ enabled: true,
292
+ applies: false,
293
+ ...(gateReason === undefined ? {} : { reason: gateReason }),
294
+ });
295
+ plan.skipped = gateReason ?? 'when returned false';
296
+ context.emit?.({
297
+ type: 'progress', phase: 'selection', completed: ++completedSelections, total: rules.length,
298
+ });
299
+ plan.durationMs = performance.now() - selectionStartedAt;
300
+ continue;
301
+ }
302
+ emit({
303
+ type: 'gate',
304
+ name,
305
+ enabled: true,
306
+ applies: true,
307
+ ...(gateReason === undefined ? {} : { reason: gateReason }),
308
+ });
309
+ const changedOnly = rule.group === 'scope' || rule.group === 'file';
310
+ let selected;
311
+ try {
312
+ const raw = await rule.select.call(rule, selectionContextFor(name, changedOnly));
313
+ const subjects = validateScopeSubjects(raw, `Rule "${name}" selection`);
314
+ selected = changedOnly && changedFiles !== undefined
315
+ ? subjects.filter(subject => changedFiles.has(subject.filepath))
316
+ : subjects;
317
+ await assertEligible(name, selected);
318
+ }
319
+ catch (error) {
320
+ throw fail(name, 'select', error);
321
+ }
322
+ const kept = [];
323
+ try {
324
+ for (const subject of selected) {
325
+ const predicate = validatePredicateResult(await rule.filter.call(rule, subject, projectContextFor(name)), `Rule "${name}" filter`);
326
+ const accepted = typeof predicate === 'boolean' ? predicate : predicate.applies;
327
+ const reason = typeof predicate === 'boolean' ? undefined : predicate.reason;
328
+ emit(reason === undefined
329
+ ? { type: 'scope', name, subject, accepted }
330
+ : { type: 'scope', name, subject, accepted, reason });
331
+ if (accepted)
332
+ kept.push(subject);
333
+ }
334
+ }
335
+ catch (error) {
336
+ throw fail(name, 'filter', error);
337
+ }
338
+ plan.subjects = kept;
339
+ if (kept.length === 0)
340
+ plan.skipped = 'no matching subjects';
341
+ context.emit?.({
342
+ type: 'progress', phase: 'selection', completed: ++completedSelections, total: rules.length,
343
+ });
344
+ plan.durationMs = performance.now() - selectionStartedAt;
345
+ }
346
+ for (const plan of plans) {
347
+ if (plan.skipped !== undefined)
348
+ continue;
349
+ const groupingStartedAt = performance.now();
350
+ const grouping = plan.rule.group;
351
+ try {
352
+ const subjects = groupSubjects(plan.subjects, grouping);
353
+ plan.evaluationSubjects = changedPaths === undefined || grouping === 'scope' || grouping === 'file'
354
+ ? subjects
355
+ : subjects.filter(subject => subject.kind === 'group' && changedPaths.some(filepath => subject.group.path === '.' || filepath.startsWith(`${subject.group.path}/`)));
356
+ for (const subject of plan.evaluationSubjects) {
357
+ if (subject.kind === 'group')
358
+ emit({ type: 'group', name: plan.name, subject });
359
+ }
360
+ if (plan.evaluationSubjects.length === 0)
361
+ plan.skipped = 'no changed subjects';
362
+ }
363
+ catch (error) {
364
+ throw fail(plan.name, 'group', error);
365
+ }
366
+ plan.durationMs += performance.now() - groupingStartedAt;
367
+ }
368
+ const tasks = [];
369
+ for (const plan of plans) {
370
+ for (const subject of plan.evaluationSubjects) {
371
+ tasks.push({ plan, subject, evaluationId: tasks.length + 1 });
372
+ }
373
+ }
374
+ context.emit?.({ type: 'progress', phase: 'evaluation', completed: 0, total: tasks.length });
375
+ const results = await mapConcurrent(tasks, context.concurrency, (task, _index, reportFailure) => evaluateSubject(task.plan, task.subject, task.evaluationId, reportFailure));
376
+ for (let index = 0; index < tasks.length; index += 1) {
377
+ const task = tasks[index];
378
+ task.plan.evaluations.push({ subject: task.subject, result: results[index] });
379
+ }
380
+ return plans.map(plan => {
381
+ const durationMs = plan.durationMs + (plan.evaluationStartedAt === undefined
382
+ ? 0
383
+ : plan.evaluationFinishedAt - plan.evaluationStartedAt);
384
+ if (plan.skipped !== undefined) {
385
+ return {
386
+ name: plan.name,
387
+ ruleFile: plan.ruleFile,
388
+ status: 'skipped',
389
+ durationMs,
390
+ reason: plan.skipped,
391
+ evaluations: [],
392
+ };
393
+ }
394
+ const status = plan.evaluations.some(evaluation => evaluation.result.status === 'violation')
395
+ ? 'violation'
396
+ : 'pass';
397
+ return { name: plan.name, ruleFile: plan.ruleFile, status, durationMs, evaluations: plan.evaluations };
398
+ });
399
+ }
400
+ function groupSubjects(subjects, grouping) {
401
+ if (grouping === 'scope')
402
+ return [...subjects];
403
+ const descriptors = [];
404
+ const buckets = new Map();
405
+ for (const subject of subjects) {
406
+ const descriptor = grouping === 'file'
407
+ ? { type: 'file', path: subject.filepath }
408
+ : grouping === 'project'
409
+ ? { type: 'project', path: '.' }
410
+ : {
411
+ type: 'folder',
412
+ path: folderPath(subject.filepath, grouping === 'folder' ? 0 : grouping.level),
413
+ };
414
+ const key = `${descriptor.type}\u0000${descriptor.path}`;
415
+ let bucket = buckets.get(key);
416
+ if (bucket === undefined) {
417
+ bucket = [];
418
+ buckets.set(key, bucket);
419
+ descriptors.push(descriptor);
420
+ }
421
+ bucket.push(subject);
422
+ }
423
+ return descriptors.map(descriptor => {
424
+ const members = buckets.get(`${descriptor.type}\u0000${descriptor.path}`);
425
+ return { kind: 'group', group: descriptor, members };
426
+ });
427
+ }
@@ -0,0 +1 @@
1
+ export declare function assertNonExecutable(selector: string, where: string): void;
@@ -0,0 +1,8 @@
1
+ import { JSONPath } from 'jsonpath-plus';
2
+ export function assertNonExecutable(selector, where) {
3
+ for (const token of JSONPath.toPathArray(selector)) {
4
+ if (token.startsWith('?(') || token.startsWith('(')) {
5
+ throw new Error(`The ${where} must not contain executable JSONPath expressions`);
6
+ }
7
+ }
8
+ }