@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.
@@ -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.actual);
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
- actual = await active.run({
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 { tests, aiTests } = await import(pathToFileURL(rule.ruleFile).href);
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
- if (selectedAI !== undefined && !Array.isArray(selectedAI)) {
211
- throw new Error(`${label} aiTests: expected an array of test cases`);
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 regularCount = tests?.length ?? 0;
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
- const ai = index >= regularCount;
225
- const caseIndex = ai ? index - regularCount : index;
226
- const value = ai ? selectedAI[caseIndex] : tests[caseIndex];
227
- const example = validateCase(value, `${label} ${ai ? 'aiTests' : 'tests'}[${caseIndex}]`);
228
- completed[index] = await runCase(rule, example, ai, timeoutMs, session, fail);
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
- export declare function changedFiles(root: string, base: string, env?: Readonly<Record<string, string | undefined>>): Promise<ReadonlySet<string>>;
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
- 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>;