@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
|
@@ -1,23 +1,310 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import { traversablePath } from '../filesystem/project.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
const INVALID_DIFF = 'Cannot collect changed lines: invalid Git diff output.';
|
|
7
|
+
const CONFLICTS = 'Cannot collect changed lines: resolve Git conflicts first.';
|
|
8
|
+
async function git(root, args, consume, env, stdin) {
|
|
9
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
10
|
+
const child = spawn('git', [...args], {
|
|
11
|
+
cwd: root,
|
|
12
|
+
env,
|
|
13
|
+
stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
14
|
+
});
|
|
15
|
+
let stderr = '';
|
|
16
|
+
let parserError;
|
|
17
|
+
let stdinError;
|
|
18
|
+
let settled = false;
|
|
19
|
+
child.stdout.setEncoding('utf8');
|
|
20
|
+
child.stderr.setEncoding('utf8');
|
|
21
|
+
child.stdout.on('data', (chunk) => {
|
|
22
|
+
if (parserError !== undefined)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
consume(chunk);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
parserError = error;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
32
|
+
child.stdin?.on('error', error => { stdinError = error; });
|
|
33
|
+
child.on('error', error => {
|
|
34
|
+
if (settled)
|
|
35
|
+
return;
|
|
36
|
+
settled = true;
|
|
37
|
+
reject(new Error(`Cannot run Git: ${error.message}`, { cause: error }));
|
|
18
38
|
});
|
|
39
|
+
child.on('close', (code, signal) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
if (parserError !== undefined)
|
|
44
|
+
reject(parserError);
|
|
45
|
+
else if (stdinError !== undefined)
|
|
46
|
+
reject(new Error(`Cannot write to Git stdin: ${stdinError.message}`, { cause: stdinError }));
|
|
47
|
+
else if (code === 0)
|
|
48
|
+
resolve();
|
|
49
|
+
else
|
|
50
|
+
reject(new Error(`Git ${args[0]} failed: ${stderr.trim() || signal || `exit ${code}`}`));
|
|
51
|
+
});
|
|
52
|
+
if (stdin !== undefined)
|
|
53
|
+
child.stdin.end(stdin);
|
|
54
|
+
return promise;
|
|
55
|
+
}
|
|
56
|
+
function invalidDiff() {
|
|
57
|
+
return new Error(INVALID_DIFF);
|
|
19
58
|
}
|
|
20
|
-
|
|
59
|
+
class DiffParser {
|
|
60
|
+
phase = 'metadata';
|
|
61
|
+
records = [];
|
|
62
|
+
pendingMode;
|
|
63
|
+
nulPending = '';
|
|
64
|
+
patchPending = '';
|
|
65
|
+
expectedBlocks = 0;
|
|
66
|
+
currentBlock;
|
|
67
|
+
selectedBlocks = [];
|
|
68
|
+
rawIndex = 0;
|
|
69
|
+
blockIndex = 0;
|
|
70
|
+
typeDelete = false;
|
|
71
|
+
consume(chunk) {
|
|
72
|
+
let rest = chunk;
|
|
73
|
+
if (this.phase !== 'patch') {
|
|
74
|
+
const patch = this.consumeRaw(rest);
|
|
75
|
+
if (patch === undefined)
|
|
76
|
+
return;
|
|
77
|
+
rest = patch;
|
|
78
|
+
}
|
|
79
|
+
const text = this.patchPending + rest;
|
|
80
|
+
let lineStart = 0;
|
|
81
|
+
let newline;
|
|
82
|
+
while ((newline = text.indexOf('\n', lineStart)) !== -1) {
|
|
83
|
+
this.consumePatchLine(text.slice(lineStart, newline).replace(/\r$/, ''));
|
|
84
|
+
lineStart = newline + 1;
|
|
85
|
+
}
|
|
86
|
+
this.patchPending = text.slice(lineStart);
|
|
87
|
+
}
|
|
88
|
+
finish() {
|
|
89
|
+
if (this.phase === 'metadata' && this.nulPending !== '' || this.phase === 'path')
|
|
90
|
+
throw invalidDiff();
|
|
91
|
+
if (this.phase === 'metadata' && this.records.length === 0 && this.nulPending === '')
|
|
92
|
+
return [];
|
|
93
|
+
if (this.phase !== 'patch')
|
|
94
|
+
throw invalidDiff();
|
|
95
|
+
if (this.patchPending !== '') {
|
|
96
|
+
this.consumePatchLine(this.patchPending.replace(/\r$/, ''));
|
|
97
|
+
this.patchPending = '';
|
|
98
|
+
}
|
|
99
|
+
this.finishBlock();
|
|
100
|
+
if (this.blockIndex !== this.expectedBlocks) {
|
|
101
|
+
throw invalidDiff();
|
|
102
|
+
}
|
|
103
|
+
if (this.rawIndex !== this.records.length || this.typeDelete)
|
|
104
|
+
throw invalidDiff();
|
|
105
|
+
return this.selectedBlocks;
|
|
106
|
+
}
|
|
107
|
+
get rawRecords() { return this.records; }
|
|
108
|
+
consumeRaw(chunk) {
|
|
109
|
+
this.nulPending += chunk;
|
|
110
|
+
let nul;
|
|
111
|
+
while ((nul = this.nulPending.indexOf('\0')) !== -1) {
|
|
112
|
+
const value = this.nulPending.slice(0, nul);
|
|
113
|
+
this.nulPending = this.nulPending.slice(nul + 1);
|
|
114
|
+
if (this.phase === 'metadata') {
|
|
115
|
+
if (value === '') {
|
|
116
|
+
this.expectedBlocks = this.records.reduce((count, record) => count + (record.status === 'T' ? 2 : 1), 0);
|
|
117
|
+
this.phase = 'patch';
|
|
118
|
+
const patch = this.nulPending;
|
|
119
|
+
this.nulPending = '';
|
|
120
|
+
return patch;
|
|
121
|
+
}
|
|
122
|
+
const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])(?:[0-9]+)?$/.exec(value);
|
|
123
|
+
if (match === null)
|
|
124
|
+
throw invalidDiff();
|
|
125
|
+
this.pendingMode = { oldMode: match[1], newMode: match[2], status: match[5] };
|
|
126
|
+
this.phase = 'path';
|
|
127
|
+
}
|
|
128
|
+
else if (this.phase === 'path') {
|
|
129
|
+
if (value === '' || this.pendingMode === undefined)
|
|
130
|
+
throw invalidDiff();
|
|
131
|
+
this.records.push({ ...this.pendingMode, path: value });
|
|
132
|
+
this.pendingMode = undefined;
|
|
133
|
+
this.phase = 'metadata';
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
throw invalidDiff();
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
consumePatchLine(line) {
|
|
141
|
+
if (line.startsWith('diff --git ')) {
|
|
142
|
+
this.finishBlock();
|
|
143
|
+
if (this.blockIndex >= this.expectedBlocks)
|
|
144
|
+
throw invalidDiff();
|
|
145
|
+
this.currentBlock = { ranges: [], binary: false, hunk: undefined };
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (this.currentBlock === undefined) {
|
|
149
|
+
if (line !== '')
|
|
150
|
+
throw invalidDiff();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (line.startsWith('Binary files ') && line.endsWith(' differ') || line === 'GIT binary patch') {
|
|
154
|
+
this.currentBlock.binary = true;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?:.*)$/.exec(line);
|
|
158
|
+
if (hunk === null && line.startsWith('@@'))
|
|
159
|
+
throw invalidDiff();
|
|
160
|
+
if (hunk !== null) {
|
|
161
|
+
const newStart = Number(hunk[3]);
|
|
162
|
+
const newCount = hunk[4] === undefined ? 1 : Number(hunk[4]);
|
|
163
|
+
this.currentBlock.hunk = {
|
|
164
|
+
oldExpected: hunk[2] === undefined ? 1 : Number(hunk[2]),
|
|
165
|
+
newExpected: newCount,
|
|
166
|
+
oldSeen: 0,
|
|
167
|
+
newSeen: 0,
|
|
168
|
+
};
|
|
169
|
+
if (newCount > 0) {
|
|
170
|
+
const range = { start: newStart, end: newStart + newCount - 1 };
|
|
171
|
+
const previous = this.currentBlock.ranges.at(-1);
|
|
172
|
+
if (previous !== undefined && range.start <= previous.end + 1) {
|
|
173
|
+
this.currentBlock.ranges[this.currentBlock.ranges.length - 1] = {
|
|
174
|
+
start: previous.start,
|
|
175
|
+
end: Math.max(previous.end, range.end),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
else
|
|
179
|
+
this.currentBlock.ranges.push(range);
|
|
180
|
+
}
|
|
181
|
+
this.finishHunkIfComplete();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (line.startsWith('\'))
|
|
185
|
+
return;
|
|
186
|
+
const active = this.currentBlock.hunk;
|
|
187
|
+
if (active !== undefined) {
|
|
188
|
+
const marker = line[0];
|
|
189
|
+
if (marker === ' ') {
|
|
190
|
+
active.oldSeen++;
|
|
191
|
+
active.newSeen++;
|
|
192
|
+
}
|
|
193
|
+
else if (marker === '+')
|
|
194
|
+
active.newSeen++;
|
|
195
|
+
else if (marker === '-')
|
|
196
|
+
active.oldSeen++;
|
|
197
|
+
else
|
|
198
|
+
throw invalidDiff();
|
|
199
|
+
if (active.oldSeen > active.oldExpected || active.newSeen > active.newExpected)
|
|
200
|
+
throw invalidDiff();
|
|
201
|
+
this.finishHunkIfComplete();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finishHunk() {
|
|
205
|
+
const hunk = this.currentBlock?.hunk;
|
|
206
|
+
if (hunk !== undefined && (hunk.oldSeen !== hunk.oldExpected || hunk.newSeen !== hunk.newExpected)) {
|
|
207
|
+
throw invalidDiff();
|
|
208
|
+
}
|
|
209
|
+
if (this.currentBlock !== undefined)
|
|
210
|
+
this.currentBlock.hunk = undefined;
|
|
211
|
+
}
|
|
212
|
+
finishHunkIfComplete() {
|
|
213
|
+
const hunk = this.currentBlock?.hunk;
|
|
214
|
+
if (hunk !== undefined && hunk.oldSeen === hunk.oldExpected && hunk.newSeen === hunk.newExpected) {
|
|
215
|
+
this.currentBlock.hunk = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
finishBlock() {
|
|
219
|
+
if (this.currentBlock === undefined)
|
|
220
|
+
return;
|
|
221
|
+
this.finishHunk();
|
|
222
|
+
const record = this.records[this.rawIndex];
|
|
223
|
+
if (record === undefined)
|
|
224
|
+
throw invalidDiff();
|
|
225
|
+
if (record.status === 'T') {
|
|
226
|
+
if (this.typeDelete) {
|
|
227
|
+
this.selectedBlocks.push(this.currentBlock);
|
|
228
|
+
this.typeDelete = false;
|
|
229
|
+
this.rawIndex++;
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
this.typeDelete = true;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this.selectedBlocks.push(this.currentBlock);
|
|
236
|
+
this.rawIndex++;
|
|
237
|
+
}
|
|
238
|
+
this.blockIndex++;
|
|
239
|
+
this.currentBlock = undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function collectNulPaths(root, args, env) {
|
|
243
|
+
let pending = '';
|
|
244
|
+
const paths = [];
|
|
245
|
+
await git(root, args, chunk => {
|
|
246
|
+
const records = (pending + chunk).split('\0');
|
|
247
|
+
pending = records.pop();
|
|
248
|
+
for (const path of records)
|
|
249
|
+
if (path !== '')
|
|
250
|
+
paths.push(path);
|
|
251
|
+
}, env);
|
|
252
|
+
if (pending !== '')
|
|
253
|
+
throw invalidDiff();
|
|
254
|
+
return paths;
|
|
255
|
+
}
|
|
256
|
+
async function readAttributes(root, paths, env) {
|
|
257
|
+
const attributed = new Map();
|
|
258
|
+
if (paths.length === 0)
|
|
259
|
+
return attributed;
|
|
260
|
+
let pending = '';
|
|
261
|
+
const fields = [];
|
|
262
|
+
await git(root, ['check-attr', '-z', '--stdin', 'filter', 'working-tree-encoding'], chunk => {
|
|
263
|
+
const records = (pending + chunk).split('\0');
|
|
264
|
+
pending = records.pop();
|
|
265
|
+
fields.push(...records);
|
|
266
|
+
}, env, `${paths.join('\0')}\0`);
|
|
267
|
+
if (pending !== '' || fields.length % 3 !== 0)
|
|
268
|
+
throw invalidDiff();
|
|
269
|
+
for (let index = 0; index < fields.length; index += 3) {
|
|
270
|
+
const path = fields[index];
|
|
271
|
+
const attribute = fields[index + 1];
|
|
272
|
+
const value = fields[index + 2];
|
|
273
|
+
if (attribute !== 'filter' && attribute !== 'working-tree-encoding')
|
|
274
|
+
throw invalidDiff();
|
|
275
|
+
if (value !== 'unspecified' && value !== 'unset')
|
|
276
|
+
attributed.set(path, true);
|
|
277
|
+
}
|
|
278
|
+
return attributed;
|
|
279
|
+
}
|
|
280
|
+
async function untrackedLines(absolute) {
|
|
281
|
+
const stats = await lstat(absolute);
|
|
282
|
+
if (!stats.isFile())
|
|
283
|
+
return undefined;
|
|
284
|
+
let prefixBytes = 0;
|
|
285
|
+
let binary = false;
|
|
286
|
+
let byteCount = 0;
|
|
287
|
+
let lfCount = 0;
|
|
288
|
+
let lastByte = -1;
|
|
289
|
+
for await (const chunk of createReadStream(absolute)) {
|
|
290
|
+
const bytes = chunk;
|
|
291
|
+
const inspectCount = Math.min(bytes.length, Math.max(0, 8000 - prefixBytes));
|
|
292
|
+
if (!binary && bytes.subarray(0, inspectCount).includes(0))
|
|
293
|
+
binary = true;
|
|
294
|
+
prefixBytes += inspectCount;
|
|
295
|
+
byteCount += bytes.length;
|
|
296
|
+
for (const byte of bytes)
|
|
297
|
+
if (byte === 0x0a)
|
|
298
|
+
lfCount++;
|
|
299
|
+
if (bytes.length > 0)
|
|
300
|
+
lastByte = bytes[bytes.length - 1];
|
|
301
|
+
}
|
|
302
|
+
if (binary)
|
|
303
|
+
return undefined;
|
|
304
|
+
const lineCount = byteCount === 0 ? 0 : lfCount + (lastByte === 0x0a ? 0 : 1);
|
|
305
|
+
return lineCount === 0 ? [] : [{ start: 1, end: lineCount }];
|
|
306
|
+
}
|
|
307
|
+
export async function collectChanges(root, base, env) {
|
|
21
308
|
let tree = '';
|
|
22
309
|
try {
|
|
23
310
|
await git(root, ['rev-parse', '--verify', '--end-of-options', `${base}^{tree}`], chunk => {
|
|
@@ -29,26 +316,73 @@ export async function changedFiles(root, base, env) {
|
|
|
29
316
|
cause: error,
|
|
30
317
|
});
|
|
31
318
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
319
|
+
let conflicts = false;
|
|
320
|
+
await git(root, ['ls-files', '--unmerged', '-z', '--', '.'], () => { conflicts = true; }, env);
|
|
321
|
+
if (conflicts)
|
|
322
|
+
throw new Error(CONFLICTS);
|
|
323
|
+
const inherited = env ?? process.env;
|
|
324
|
+
const diffEnv = { ...inherited };
|
|
325
|
+
delete diffEnv.GIT_DIFF_OPTS;
|
|
326
|
+
const parser = new DiffParser();
|
|
327
|
+
await git(root, [
|
|
328
|
+
'diff', '--relative', '--raw', '-z', '--patch', '--unified=0', '--inter-hunk-context=0',
|
|
329
|
+
'--no-renames', '--no-ext-diff', '--no-textconv', '--no-color', '--submodule=short', '-O/dev/null',
|
|
330
|
+
'--output-indicator-new=+', '--output-indicator-old=-', '--output-indicator-context= ',
|
|
44
331
|
tree.trim(), '--', '.',
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
332
|
+
], chunk => parser.consume(chunk), diffEnv);
|
|
333
|
+
const blocks = parser.finish();
|
|
334
|
+
const records = parser.rawRecords;
|
|
335
|
+
const trackedPaths = records.filter(record => record.newMode.startsWith('100')).map(record => record.path);
|
|
336
|
+
const attributes = await readAttributes(root, trackedPaths, env);
|
|
337
|
+
const changedFiles = new Set();
|
|
338
|
+
const lineResults = new Map();
|
|
339
|
+
let selectedBlock = 0;
|
|
340
|
+
for (const record of records) {
|
|
341
|
+
const physical = await traversablePath(root, record.path);
|
|
342
|
+
const currentBlock = blocks[selectedBlock++];
|
|
343
|
+
if (physical === undefined)
|
|
344
|
+
continue;
|
|
345
|
+
changedFiles.add(physical);
|
|
346
|
+
const isCurrent = record.newMode !== '000000';
|
|
347
|
+
if (!isCurrent) {
|
|
348
|
+
if (!lineResults.has(physical))
|
|
349
|
+
lineResults.set(physical, []);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!record.newMode.startsWith('100')) {
|
|
353
|
+
lineResults.set(physical, undefined);
|
|
354
|
+
}
|
|
355
|
+
else if (currentBlock === undefined || currentBlock.binary) {
|
|
356
|
+
lineResults.set(physical, undefined);
|
|
357
|
+
}
|
|
358
|
+
else if (currentBlock.ranges.length === 0) {
|
|
359
|
+
lineResults.set(physical, []);
|
|
360
|
+
}
|
|
361
|
+
else if (attributes.get(record.path) === true) {
|
|
362
|
+
lineResults.set(physical, undefined);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
lineResults.set(physical, currentBlock.ranges);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const untracked = await collectNulPaths(root, ['ls-files', '--others', '--exclude-standard', '-z', '--', '.'], env);
|
|
369
|
+
for (const path of untracked) {
|
|
49
370
|
const physical = await traversablePath(root, path);
|
|
50
|
-
if (physical
|
|
51
|
-
|
|
371
|
+
if (physical === undefined)
|
|
372
|
+
continue;
|
|
373
|
+
const lines = await untrackedLines(join(root, ...path.split('/')));
|
|
374
|
+
changedFiles.add(physical);
|
|
375
|
+
if (lines === undefined) {
|
|
376
|
+
lineResults.set(physical, undefined);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
lineResults.set(physical, lines);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const changedLines = new Map();
|
|
383
|
+
for (const [path, ranges] of lineResults) {
|
|
384
|
+
if (ranges !== undefined)
|
|
385
|
+
changedLines.set(path, ranges);
|
|
52
386
|
}
|
|
53
|
-
return
|
|
387
|
+
return { changedFiles, changedLines };
|
|
54
388
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export interface RuleRepositoryCheckout {
|
|
2
|
+
readonly root: string;
|
|
3
|
+
cleanup(): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export declare function createRuleRepositoryCheckout(env: Readonly<Record<string, string | undefined>>): (selector: string) => Promise<RuleRepositoryCheckout | undefined>;
|
|
@@ -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/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;
|