@alex-dbs/coderuler 1.3.1 → 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 +8 -3
- package/dist/adapter/cli/commands/check.js +21 -10
- package/dist/adapter/cli/commands/test.js +2 -2
- package/dist/adapter/cli/program.js +2 -1
- package/dist/adapter/cli/reports.d.ts +1 -1
- package/dist/adapter/cli/reports.js +5 -2
- package/dist/adapter/cli/services.d.ts +5 -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 +6 -1
- package/dist/adapter/filesystem/rule-test-worker.js +13 -3
- package/dist/adapter/filesystem/rule-tests.d.ts +1 -1
- package/dist/adapter/filesystem/rule-tests.js +69 -21
- 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/evaluators.js +11 -4
- package/dist/app/execute.js +9 -9
- package/dist/app/ports.d.ts +6 -1
- package/dist/app/rule-tests.d.ts +1 -0
- package/dist/app/rule.d.ts +21 -13
- 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 +20 -1
- package/skills/coderuler-new-rule/SKILL.md +11 -5
- package/skills/coderuler-new-rule/reference.md +121 -53
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { Worker } from 'node:worker_threads';
|
|
6
6
|
import { isRecord, validateJsonValue } from '../../app/validate.js';
|
|
7
|
-
import { getRuleDependencies } from './rule-resolution.js';
|
|
7
|
+
import { getRuleDependencies, registerRuleDependencies } from './rule-resolution.js';
|
|
8
8
|
// A JavaScript entry lets inherited tsx hooks resolve the source worker's .js import.
|
|
9
9
|
const WORKER_MODULE = new URL('./rule-test-worker.js', import.meta.url);
|
|
10
10
|
const WORKER_ENTRY = new URL(`data:text/javascript,${encodeURIComponent(`import ${JSON.stringify(WORKER_MODULE.href)};`)}`);
|
|
@@ -24,6 +24,35 @@ function validateCase(value, label) {
|
|
|
24
24
|
}
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
|
+
async function loadExternalTests(ruleFile, testFile) {
|
|
28
|
+
try {
|
|
29
|
+
await lstat(testFile);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT')
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const dependencies = getRuleDependencies(ruleFile);
|
|
38
|
+
if (dependencies !== undefined)
|
|
39
|
+
registerRuleDependencies(testFile, dependencies);
|
|
40
|
+
// The companion path comes from the selected rule filename.
|
|
41
|
+
return await import(pathToFileURL(testFile).href);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
45
|
+
throw new Error(`Rule test module ${testFile} failed to import: ${detail}`, { cause: error });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function addTestGroup(groups, values, sourceLabel, exportName, ai) {
|
|
49
|
+
if (values === undefined)
|
|
50
|
+
return;
|
|
51
|
+
if (!Array.isArray(values)) {
|
|
52
|
+
throw new Error(`${sourceLabel} ${exportName}: expected an array of test cases`);
|
|
53
|
+
}
|
|
54
|
+
groups.push({ values, label: `${sourceLabel} ${exportName}`, ai });
|
|
55
|
+
}
|
|
27
56
|
class RuleTestWorkerSession {
|
|
28
57
|
worker = new Worker(WORKER_ENTRY);
|
|
29
58
|
nextId = 0;
|
|
@@ -75,7 +104,7 @@ class RuleTestWorkerSession {
|
|
|
75
104
|
else if ('error' in message)
|
|
76
105
|
result.reject(new Error(message.error));
|
|
77
106
|
else
|
|
78
|
-
result.resolve(message.
|
|
107
|
+
result.resolve(message.outcome);
|
|
79
108
|
};
|
|
80
109
|
this.rejectPending = error => {
|
|
81
110
|
if (performance.now() >= deadline)
|
|
@@ -114,7 +143,7 @@ async function stopSession(session) {
|
|
|
114
143
|
if (session.current === current)
|
|
115
144
|
session.current = undefined;
|
|
116
145
|
}
|
|
117
|
-
async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
146
|
+
async function runCase(entry, example, ai, timeoutMs, session, onFailure, includeDisabled) {
|
|
118
147
|
const started = performance.now();
|
|
119
148
|
const deadline = started + timeoutMs;
|
|
120
149
|
let failure;
|
|
@@ -129,6 +158,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
129
158
|
try {
|
|
130
159
|
const root = await mkdtemp(join(await realpath(tmpdir()), 'coderuler-test-'));
|
|
131
160
|
let actual;
|
|
161
|
+
let aiScores;
|
|
132
162
|
let active;
|
|
133
163
|
try {
|
|
134
164
|
if (session.cancelled)
|
|
@@ -138,10 +168,17 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
138
168
|
else {
|
|
139
169
|
active = session.current ??= new RuleTestWorkerSession();
|
|
140
170
|
const dependencies = getRuleDependencies(entry.ruleFile);
|
|
141
|
-
|
|
171
|
+
const outcome = await active.run({
|
|
142
172
|
root, ruleFile: entry.ruleFile, files: example.files, ai,
|
|
173
|
+
...(includeDisabled ? { includeDisabled: true } : {}),
|
|
143
174
|
...(dependencies === undefined ? {} : { dependencies }),
|
|
144
175
|
}, deadline, failCase);
|
|
176
|
+
if (typeof outcome === 'string')
|
|
177
|
+
actual = outcome;
|
|
178
|
+
else {
|
|
179
|
+
actual = outcome.actual;
|
|
180
|
+
aiScores = outcome.aiScores;
|
|
181
|
+
}
|
|
145
182
|
}
|
|
146
183
|
}
|
|
147
184
|
catch (error) {
|
|
@@ -164,6 +201,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
164
201
|
expected: example.expected,
|
|
165
202
|
actual,
|
|
166
203
|
durationMs: performance.now() - started,
|
|
204
|
+
...(aiScores === undefined ? {} : { aiScores }),
|
|
167
205
|
};
|
|
168
206
|
}
|
|
169
207
|
catch (error) {
|
|
@@ -172,7 +210,7 @@ async function runCase(entry, example, ai, timeoutMs, session, onFailure) {
|
|
|
172
210
|
throw cause;
|
|
173
211
|
}
|
|
174
212
|
}
|
|
175
|
-
export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false) {
|
|
213
|
+
export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurrency = 1, includeAI = false, includeDisabled = false) {
|
|
176
214
|
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
177
215
|
throw new RangeError(`--concurrency must be a positive safe integer, received ${String(concurrency)}`);
|
|
178
216
|
}
|
|
@@ -196,22 +234,28 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
|
|
|
196
234
|
for (const rule of rules) {
|
|
197
235
|
if (failureObserved)
|
|
198
236
|
throw failure;
|
|
237
|
+
if (!includeDisabled && !rule.rule.enabled)
|
|
238
|
+
continue;
|
|
199
239
|
// Discovery selects the rule module path at runtime.
|
|
200
|
-
const
|
|
240
|
+
const ruleModule = await import(pathToFileURL(rule.ruleFile).href);
|
|
241
|
+
const testFile = rule.ruleFile.replace(/\.rule\.(ts|mts|js|mjs)$/, '.test.$1');
|
|
242
|
+
const externalModule = await loadExternalTests(rule.ruleFile, testFile);
|
|
243
|
+
const { tests, aiTests } = ruleModule;
|
|
201
244
|
if (failureObserved)
|
|
202
245
|
throw failure;
|
|
203
|
-
if (!includeAI && aiTests !== undefined)
|
|
246
|
+
if (!includeAI && (aiTests !== undefined || externalModule?.aiTests !== undefined)) {
|
|
204
247
|
hasExcludedAITests = true;
|
|
205
|
-
const selectedAI = includeAI ? aiTests : undefined;
|
|
206
|
-
const label = `Rule "${rule.name}"`;
|
|
207
|
-
if (tests !== undefined && !Array.isArray(tests)) {
|
|
208
|
-
throw new Error(`${label} tests: expected an array of test cases`);
|
|
209
248
|
}
|
|
210
|
-
|
|
211
|
-
|
|
249
|
+
const label = `Rule "${rule.name}"`;
|
|
250
|
+
const externalLabel = `${label} test module ${testFile}`;
|
|
251
|
+
const groups = [];
|
|
252
|
+
addTestGroup(groups, tests, label, 'tests', false);
|
|
253
|
+
addTestGroup(groups, externalModule?.tests, externalLabel, 'tests', false);
|
|
254
|
+
if (includeAI) {
|
|
255
|
+
addTestGroup(groups, aiTests, label, 'aiTests', true);
|
|
256
|
+
addTestGroup(groups, externalModule?.aiTests, externalLabel, 'aiTests', true);
|
|
212
257
|
}
|
|
213
|
-
const
|
|
214
|
-
const count = regularCount + (selectedAI?.length ?? 0);
|
|
258
|
+
const count = groups.reduce((total, group) => total + group.values.length, 0);
|
|
215
259
|
const completed = new Array(count);
|
|
216
260
|
let nextIndex = 0;
|
|
217
261
|
let nextResult = 0;
|
|
@@ -221,11 +265,15 @@ export async function runRuleTests(rules, onResult, timeoutMs = 10_000, concurre
|
|
|
221
265
|
if (index >= count)
|
|
222
266
|
return;
|
|
223
267
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
268
|
+
let groupIndex = 0;
|
|
269
|
+
let caseIndex = index;
|
|
270
|
+
while (caseIndex >= groups[groupIndex].values.length) {
|
|
271
|
+
caseIndex -= groups[groupIndex].values.length;
|
|
272
|
+
groupIndex += 1;
|
|
273
|
+
}
|
|
274
|
+
const group = groups[groupIndex];
|
|
275
|
+
const example = validateCase(group.values[caseIndex], `${group.label}[${caseIndex}]`);
|
|
276
|
+
completed[index] = await runCase(rule, example, group.ai, timeoutMs, session, fail, includeDisabled);
|
|
229
277
|
if (failureObserved)
|
|
230
278
|
return;
|
|
231
279
|
while (completed[nextResult] !== undefined) {
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import type { DiffChanges } from '../../app/ports.js';
|
|
2
|
+
export declare function collectChanges(root: string, base: string, env?: Readonly<Record<string, string | undefined>>): Promise<DiffChanges>;
|
|
@@ -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>;
|