@alex-dbs/coderuler 1.4.0 → 1.6.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-lsp.d.ts +6 -0
- package/dist/adapter/filesystem/rule-test-lsp.js +189 -0
- package/dist/adapter/filesystem/rule-test-worker.d.ts +5 -0
- package/dist/adapter/filesystem/rule-test-worker.js +48 -26
- package/dist/adapter/filesystem/rule-tests.d.ts +2 -2
- package/dist/adapter/filesystem/rule-tests.js +170 -29
- 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/adapter/lsp/backend.d.ts +12 -0
- package/dist/adapter/lsp/backend.js +826 -0
- package/dist/app/concurrency.d.ts +1 -1
- package/dist/app/concurrency.js +12 -8
- package/dist/app/execute.js +84 -22
- package/dist/app/lsp.d.ts +6 -0
- package/dist/app/lsp.js +141 -0
- package/dist/app/ports.d.ts +13 -1
- package/dist/app/rule.d.ts +52 -10
- package/dist/app/rule.js +14 -0
- package/dist/app/validate.js +3 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/infra/runtime.js +27 -8
- package/dist/infra/settings.js +19 -8
- package/package.json +3 -1
- package/skills/coderuler-new-rule/SKILL.md +7 -3
- package/skills/coderuler-new-rule/reference.md +80 -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();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LspBackend, Project } from '../../app/ports.js';
|
|
2
|
+
export interface LspServerOptions {
|
|
3
|
+
readonly command: string;
|
|
4
|
+
readonly args?: readonly string[];
|
|
5
|
+
readonly languageId: string;
|
|
6
|
+
readonly initializationProgressToken?: string | number;
|
|
7
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
8
|
+
readonly requestTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function createLspBackend(project: Project, signal: AbortSignal, options?: LspServerOptions): Promise<LspBackend>;
|
|
11
|
+
export declare function createRubyLspBackend(project: Project, signal: AbortSignal): Promise<LspBackend>;
|
|
12
|
+
export declare function createTypescriptLspBackend(project: Project, signal: AbortSignal): Promise<LspBackend>;
|