@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.
Files changed (38) hide show
  1. package/README.md +3 -1
  2. package/dist/adapter/cli/commands/check.js +21 -10
  3. package/dist/adapter/cli/program.js +2 -2
  4. package/dist/adapter/cli/reports.js +1 -1
  5. package/dist/adapter/cli/services.d.ts +4 -2
  6. package/dist/adapter/decision/jev.js +32 -15
  7. package/dist/adapter/decision/openai.d.ts +11 -0
  8. package/dist/adapter/decision/openai.js +160 -0
  9. package/dist/adapter/filesystem/rule-test-lsp.d.ts +6 -0
  10. package/dist/adapter/filesystem/rule-test-lsp.js +189 -0
  11. package/dist/adapter/filesystem/rule-test-worker.d.ts +5 -0
  12. package/dist/adapter/filesystem/rule-test-worker.js +48 -26
  13. package/dist/adapter/filesystem/rule-tests.d.ts +2 -2
  14. package/dist/adapter/filesystem/rule-tests.js +170 -29
  15. package/dist/adapter/git/changes.d.ts +2 -1
  16. package/dist/adapter/git/changes.js +369 -35
  17. package/dist/adapter/git/rules.d.ts +5 -0
  18. package/dist/adapter/git/rules.js +126 -0
  19. package/dist/adapter/github/pull-request.d.ts +2 -0
  20. package/dist/adapter/github/pull-request.js +9 -3
  21. package/dist/adapter/lsp/backend.d.ts +12 -0
  22. package/dist/adapter/lsp/backend.js +826 -0
  23. package/dist/app/concurrency.d.ts +1 -1
  24. package/dist/app/concurrency.js +12 -8
  25. package/dist/app/execute.js +84 -22
  26. package/dist/app/lsp.d.ts +6 -0
  27. package/dist/app/lsp.js +141 -0
  28. package/dist/app/ports.d.ts +13 -1
  29. package/dist/app/rule.d.ts +52 -10
  30. package/dist/app/rule.js +14 -0
  31. package/dist/app/validate.js +3 -2
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.js +1 -1
  34. package/dist/infra/runtime.js +27 -8
  35. package/dist/infra/settings.js +19 -8
  36. package/package.json +3 -1
  37. package/skills/coderuler-new-rule/SKILL.md +7 -3
  38. 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
- async function git(root, args, consume, env) {
4
- await new Promise((resolve, reject) => {
5
- const child = spawn('git', [...args], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
6
- let stderr = '';
7
- child.stdout.setEncoding('utf8');
8
- child.stderr.setEncoding('utf8');
9
- child.stdout.on('data', consume);
10
- child.stderr.on('data', (chunk) => { stderr += chunk; });
11
- child.on('error', error => reject(new Error(`Cannot run Git: ${error.message}`, { cause: error })));
12
- child.on('close', (code, signal) => {
13
- if (code === 0)
14
- resolve();
15
- else
16
- reject(new Error(`Git ${args[0]} failed: ${stderr.trim() || signal || `exit ${code}`}`));
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
- export async function changedFiles(root, base, env) {
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
- const paths = new Set();
33
- const collectPaths = async (args) => {
34
- let pending = '';
35
- await git(root, args, chunk => {
36
- const records = (pending + chunk).split('\0');
37
- pending = records.pop();
38
- for (const path of records)
39
- paths.add(path);
40
- }, env);
41
- };
42
- await collectPaths([
43
- 'diff', '--relative', '--name-only', '-z', '--no-renames', '--no-ext-diff', '--no-textconv',
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
- await collectPaths(['ls-files', '--others', '--exclude-standard', '-z', '--', '.']);
47
- const changed = new Set();
48
- for (const path of paths) {
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 !== undefined)
51
- changed.add(physical);
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 changed;
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 { changedFiles } from '../git/changes.js';
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 files = await changedFiles(root, mergeBase, isolatedEnv);
130
+ const changes = await collectChanges(root, mergeBase, isolatedEnv);
131
131
  await assertNoWorktreeOverride(rulesRoot);
132
- return { root, rulesRoot, changedFiles: files, pullRequest: { ...pullRequest, head }, cleanup };
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>;