@h1v35/hivex 0.2.0 → 0.2.1

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/src/errors.ts CHANGED
@@ -1,13 +1,15 @@
1
+ interface HivexErrorOptions extends ErrorOptions {
2
+ code: string;
3
+ details?: Readonly<Record<string, unknown>>;
4
+ message: string;
5
+ }
6
+
1
7
  export class HivexError extends Error {
2
8
  readonly code: string;
3
9
  readonly details: Readonly<Record<string, unknown>> | undefined;
4
10
 
5
- constructor(options: {
6
- code: string;
7
- message: string;
8
- details?: Readonly<Record<string, unknown>>;
9
- }) {
10
- super(options.message);
11
+ constructor(options: HivexErrorOptions) {
12
+ super(options.message, options);
11
13
  this.name = 'HivexError';
12
14
  this.code = options.code;
13
15
  this.details = options.details;
@@ -1,101 +1,154 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { lstatSync, readFileSync, readlinkSync, realpathSync } from 'node:fs';
3
- import { resolve } from 'node:path';
3
+ import pathModule from 'node:path';
4
+ import { compareSerializedStrings } from './ordering.ts';
4
5
  import { HivexError } from './errors.ts';
5
6
  import { digest } from './knowledge-model.ts';
6
7
  import { rawMarkdownLines, lineContent } from './markdown.ts';
7
8
 
8
- type Version = { version: string; lines: Array<[number, string]> };
9
- export type Implementation = {
9
+ interface Version {
10
+ version: string;
11
+ lines: [number, string][];
12
+ }
13
+ export interface Implementation {
10
14
  baseCommit: string;
11
15
  fingerprint: string;
12
16
  diff: string;
13
- files: Array<{ path: string; before: Version | null; after: Version | null }>;
17
+ files: { path: string; before: Version | null; after: Version | null }[];
14
18
  warnings: string[];
15
- };
19
+ }
20
+ interface GitContext {
21
+ base: string;
22
+ root: string;
23
+ warnings: string[];
24
+ }
16
25
  const maxBytes = 256 * 1024;
17
26
  const maxFileBytes = 4 * 1024 * 1024;
18
27
  const protectedDirectories = new Set(['.git', '.hivex', 'node_modules', '.codex']);
28
+ const gitOptionalLocks = 'GIT_OPTIONAL_LOCKS';
29
+ const localeAll = 'LC_ALL';
19
30
  const decoder = new TextDecoder('utf-8', { fatal: true });
20
31
 
21
- function git(root: string, args: string[]) {
22
- const result = spawnSync('git', ['--literal-pathspecs', ...args], {
32
+ const isMissingFile = function isMissingFile(error: unknown) {
33
+ return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
34
+ };
35
+
36
+ const git = function git(root: string, gitArguments: string[]) {
37
+ const executable = Bun.which('git') ?? 'git';
38
+ const result = spawnSync(executable, ['--literal-pathspecs', ...gitArguments], {
23
39
  cwd: root,
40
+ env: {
41
+ ...process.env,
42
+ [gitOptionalLocks]: '0',
43
+ [localeAll]: 'C',
44
+ },
24
45
  maxBuffer: maxFileBytes + 1,
25
- timeout: 30000,
26
- env: { ...process.env, LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' },
46
+ timeout: 30_000,
27
47
  });
28
48
  if (result.error || result.status !== 0) {
29
- const tooLarge = result.error && 'code' in result.error && result.error.code === 'ENOBUFS';
49
+ const isTooLarge =
50
+ result.error !== undefined && 'code' in result.error && result.error.code === 'ENOBUFS';
30
51
  throw new HivexError({
31
- code: tooLarge ? 'IMPLEMENTATION_TOO_LARGE' : 'GIT_COMMAND_FAILED',
32
- message: result.error?.message ?? result.stderr.toString('utf8').trim().slice(0, 1024),
52
+ code: isTooLarge ? 'IMPLEMENTATION_TOO_LARGE' : 'GIT_COMMAND_FAILED',
53
+ message: result.error?.message ?? result.stderr.toString('utf-8').trim().slice(0, 1024),
33
54
  });
34
55
  }
35
56
  return result.stdout;
36
- }
37
- function text(root: string, args: string[]) {
38
- return decoder.decode(git(root, args));
39
- }
40
- function checkSize(bytes: number, limit = maxBytes) {
41
- if (bytes > limit)
57
+ };
58
+
59
+ const text = function text(root: string, gitArguments: string[]) {
60
+ return decoder.decode(git(root, gitArguments));
61
+ };
62
+
63
+ const checkSize = function checkSize(bytes: number, limit = maxBytes) {
64
+ if (bytes > limit) {
42
65
  throw new HivexError({
43
66
  code: 'IMPLEMENTATION_TOO_LARGE',
44
67
  message: `Implementation exceeds ${limit} bytes; split the change into coherent reviews.`,
45
68
  });
46
- }
47
- function version(bytes: Buffer, label: string, warnings: string[]): Version | null {
69
+ }
70
+ };
71
+
72
+ const unsupportedVersion = function unsupportedVersion(
73
+ bytes: Buffer,
74
+ label: string,
75
+ warnings: string[]
76
+ ): null {
77
+ warnings.push(
78
+ `Unsupported binary or invalid UTF-8 content: ${label} (${digest(bytes.toBase64())})`
79
+ );
80
+ return null;
81
+ };
82
+
83
+ const version = function version(bytes: Buffer, label: string, warnings: string[]): Version | null {
48
84
  checkSize(bytes.byteLength, maxFileBytes);
49
85
  let content: string;
50
86
  try {
51
87
  content = decoder.decode(bytes);
52
- if (content.includes('\0')) throw new Error('binary');
53
88
  } catch {
54
- warnings.push(
55
- `Unsupported binary or invalid UTF-8 content: ${label} (${digest(bytes.toString('base64'))})`,
56
- );
57
- return null;
89
+ return unsupportedVersion(bytes, label, warnings);
90
+ }
91
+ if (content.includes('\u{0}')) {
92
+ return unsupportedVersion(bytes, label, warnings);
58
93
  }
59
94
  return {
95
+ lines: rawMarkdownLines(content).map<[number, string]>((line, index) => {
96
+ const lineNumber = index + 1;
97
+ return [lineNumber, lineContent(line)];
98
+ }),
60
99
  version: digest(bytes),
61
- lines: rawMarkdownLines(content).map((line, index) => [index + 1, lineContent(line)]),
62
100
  };
63
- }
64
- function beforeVersion(root: string, base: string, path: string, warnings: string[]) {
101
+ };
102
+
103
+ const beforeVersion = function beforeVersion(context: GitContext, path: string) {
104
+ const { base, root, warnings } = context;
65
105
  const entry = text(root, ['ls-tree', '-z', base, '--', path])
66
106
  .split('\0')
67
107
  .find((row) => row.slice(row.indexOf('\t') + 1) === path);
68
- if (!entry) return null;
69
- const [mode, kind, object] = entry.split('\t')[0]!.split(' ');
70
- if (kind !== 'blob' || !mode?.startsWith('100')) {
108
+ if (entry === undefined) {
109
+ return null;
110
+ }
111
+ const header = entry.slice(0, entry.indexOf('\t'));
112
+ const [mode, kind, object] = header.split(' ', 3);
113
+ if (kind !== 'blob' || mode === undefined || object === undefined) {
71
114
  warnings.push(`Unsupported base file: ${path} (${mode} ${object})`);
72
115
  return null;
73
116
  }
74
- return version(git(root, ['cat-file', 'blob', object!]), `before ${path}`, warnings);
75
- }
76
- function afterVersion(root: string, path: string, warnings: string[]) {
77
- const absolute = resolve(root, path);
117
+ if (!mode.startsWith('100')) {
118
+ warnings.push(`Unsupported base file: ${path} (${mode} ${object})`);
119
+ return null;
120
+ }
121
+ return version(git(root, ['cat-file', 'blob', object]), `before ${path}`, warnings);
122
+ };
123
+
124
+ const afterVersion = function afterVersion(context: GitContext, path: string) {
125
+ const { root, warnings } = context;
126
+ const absolute = pathModule.resolve(root, path);
78
127
  let stat;
79
128
  try {
80
129
  stat = lstatSync(absolute);
81
130
  } catch (error) {
82
- if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
131
+ if (isMissingFile(error)) {
83
132
  return null;
133
+ }
84
134
  throw error;
85
135
  }
86
136
  if (stat.isSymbolicLink()) {
87
137
  warnings.push(`Unsupported working symlink: ${path} (${digest(readlinkSync(absolute))})`);
88
138
  return null;
89
139
  }
90
- if (!stat.isFile() || !realpathSync(absolute).startsWith(root + '/')) {
140
+ if (!stat.isFile() || !realpathSync(absolute).startsWith(`${root}/`)) {
91
141
  warnings.push(`Unsupported working file: ${path}`);
92
142
  return null;
93
143
  }
94
144
  checkSize(stat.size, maxFileBytes);
95
145
  return version(readFileSync(absolute), `after ${path}`, warnings);
96
- }
97
- function patch(root: string, base: string, paths: string[]) {
98
- if (!paths.length) return '';
146
+ };
147
+
148
+ const patch = function patch(root: string, base: string, paths: string[]) {
149
+ if (!paths.length) {
150
+ return '';
151
+ }
99
152
  return text(root, [
100
153
  'diff',
101
154
  '--no-ext-diff',
@@ -109,42 +162,72 @@ function patch(root: string, base: string, paths: string[]) {
109
162
  '--',
110
163
  ...paths,
111
164
  ]);
112
- }
113
- function fileContext(
114
- root: string,
115
- base: string,
116
- file: Implementation['files'][number],
117
- warnings: string[],
165
+ };
166
+
167
+ const fileContext = function fileContext(
168
+ context: GitContext,
169
+ file: Implementation['files'][number]
118
170
  ) {
119
- if (Buffer.byteLength(JSON.stringify(file)) <= 32768) return file;
120
- const hunks = [
121
- ...patch(root, base, [file.path]).matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm),
122
- ];
123
- if (!hunks.length) return file;
124
- const excerpt = (version: Version | null, offset: number) =>
125
- version && {
126
- ...version,
127
- lines: version.lines.filter(([line]) =>
128
- hunks.some(
129
- (hunk) =>
130
- line >= Number(hunk[offset]) &&
131
- line < Number(hunk[offset]) + Number(hunk[offset + 1] ?? 1),
132
- ),
133
- ),
171
+ const { base, root, warnings } = context;
172
+ if (Buffer.byteLength(JSON.stringify(file)) <= 32_768) {
173
+ return file;
174
+ }
175
+ const hunks = patch(root, base, [file.path])
176
+ .matchAll(
177
+ /^@@ -(?<beforeStart>\d+)(?:,(?<beforeLength>\d+))? \+(?<afterStart>\d+)(?:,(?<afterLength>\d+))? @@/gmu
178
+ )
179
+ .toArray();
180
+ if (!hunks.length) {
181
+ return file;
182
+ }
183
+ const excerpt = function excerpt(
184
+ documentVersion: Version | null,
185
+ startGroup: 'beforeStart' | 'afterStart',
186
+ lengthGroup: 'beforeLength' | 'afterLength'
187
+ ) {
188
+ if (documentVersion === null) {
189
+ return null;
190
+ }
191
+ const isLineInHunk = function isLineInHunk([line]: [number, string]) {
192
+ return hunks.some((hunk) => {
193
+ const { groups } = hunk;
194
+ if (groups === undefined) {
195
+ return false;
196
+ }
197
+ const start = Number(groups[startGroup]);
198
+ const length = Number(groups[lengthGroup] ?? 1);
199
+ return line >= start && line < start + length;
200
+ });
134
201
  };
202
+ return {
203
+ lines: documentVersion.lines.filter(isLineInHunk),
204
+ version: documentVersion.version,
205
+ };
206
+ };
135
207
  warnings.push(`Only changed ranges are supplied for ${file.path}; unchanged code is omitted.`);
136
- return { ...file, before: excerpt(file.before, 1), after: excerpt(file.after, 3) };
137
- }
208
+ return {
209
+ ...file,
210
+ after: excerpt(file.after, 'afterStart', 'afterLength'),
211
+ before: excerpt(file.before, 'beforeStart', 'beforeLength'),
212
+ };
213
+ };
138
214
 
139
- export function captureImplementation(root: string, base: string): Implementation {
140
- const actualRoot = realpathSync(resolve(root));
141
- if (realpathSync(text(actualRoot, ['rev-parse', '--show-toplevel']).trim()) !== actualRoot)
142
- throw new HivexError({ code: 'INVALID_ROOT', message: 'Review from the Git project root.' });
215
+ export const captureImplementation = function captureImplementation(
216
+ root: string,
217
+ base: string
218
+ ): Implementation {
219
+ const actualRoot = realpathSync(pathModule.resolve(root));
220
+ if (realpathSync(text(actualRoot, ['rev-parse', '--show-toplevel']).trim()) !== actualRoot) {
221
+ throw new HivexError({
222
+ code: 'INVALID_ROOT',
223
+ message: 'Review from the Git project root.',
224
+ });
225
+ }
143
226
  const baseCommit = text(actualRoot, [
144
227
  'rev-parse',
145
228
  '--verify',
146
229
  '--end-of-options',
147
- base + '^{commit}',
230
+ base.concat('^{commit}'),
148
231
  ]).trim();
149
232
  const tracked = text(actualRoot, [
150
233
  'diff',
@@ -157,29 +240,32 @@ export function captureImplementation(root: string, base: string): Implementatio
157
240
  '--',
158
241
  ]).split('\0');
159
242
  const untracked = text(actualRoot, ['ls-files', '--others', '--exclude-standard', '-z']).split(
160
- '\0',
243
+ '\0'
161
244
  );
162
- const paths = [...new Set([...tracked, ...untracked])]
163
- .filter((path) => path && !path.split('/').some((part) => protectedDirectories.has(part)))
164
- .sort();
165
- if (paths.length > 64)
245
+ const changedPaths = new Set(Iterator.concat(tracked, untracked));
246
+ const paths = changedPaths
247
+ .values()
248
+ .filter((path) => {
249
+ const isOutsideProtectedDirectory = path
250
+ .split('/')
251
+ .every((part) => !protectedDirectories.has(part));
252
+ return path.length > 0 && isOutsideProtectedDirectory;
253
+ })
254
+ .toArray()
255
+ .toSorted(compareSerializedStrings);
256
+ if (paths.length > 64) {
166
257
  throw new HivexError({
167
258
  code: 'IMPLEMENTATION_TOO_LARGE',
168
259
  message: 'Implementation exceeds 64 files; split the change into coherent reviews.',
169
260
  });
261
+ }
170
262
  const warnings: string[] = [];
171
- const files = paths.map((path) =>
172
- fileContext(
173
- actualRoot,
174
- baseCommit,
175
- {
176
- path,
177
- before: beforeVersion(actualRoot, baseCommit, path, warnings),
178
- after: afterVersion(actualRoot, path, warnings),
179
- },
180
- warnings,
181
- ),
182
- );
263
+ const context = { base: baseCommit, root: actualRoot, warnings };
264
+ const files = paths.map((path) => {
265
+ const before = beforeVersion(context, path);
266
+ const after = afterVersion(context, path);
267
+ return fileContext(context, { after, before, path });
268
+ });
183
269
  let diff = patch(actualRoot, baseCommit, paths);
184
270
  diff += paths
185
271
  .filter((path) => untracked.includes(path))
@@ -187,5 +273,17 @@ export function captureImplementation(root: string, base: string): Implementatio
187
273
  .join('');
188
274
  const packet = { baseCommit, diff, files, warnings };
189
275
  checkSize(Buffer.byteLength(JSON.stringify(packet)));
190
- return { ...packet, fingerprint: digest(JSON.stringify(packet)) };
191
- }
276
+ // Retain the nested field order of the original persisted fingerprint format.
277
+ const fields = [
278
+ 'baseCommit',
279
+ 'diff',
280
+ 'files',
281
+ 'warnings',
282
+ 'path',
283
+ 'before',
284
+ 'after',
285
+ 'version',
286
+ 'lines',
287
+ ];
288
+ return { ...packet, fingerprint: digest(JSON.stringify(packet, fields)) };
289
+ };
@@ -1,93 +1,125 @@
1
- import type { Document } from './documents.ts';
2
- import { rawMarkdownLines } from './markdown.ts';
3
1
  import { digest } from './knowledge-model.ts';
2
+ import { rawMarkdownLines } from './markdown.ts';
3
+ import type { Document } from './documents.ts';
4
4
 
5
- const MAX_BYTES = 8192;
5
+ const maxBytes = 8192;
6
6
 
7
- export type IngestionUnit = {
8
- id: string;
7
+ export interface IngestionUnit {
9
8
  document: string;
10
9
  hash: string;
11
- lineStart: number;
10
+ id: string;
12
11
  lineEnd: number;
12
+ lineStart: number;
13
13
  text: string;
14
- };
14
+ }
15
15
 
16
- type SourceLine = {
17
- number: number;
18
- text: string;
19
- bytes: number;
16
+ interface SourceLine {
20
17
  blank: boolean;
21
- heading: boolean;
18
+ bytes: number;
22
19
  fence: { marker: string; length: number; closing: boolean } | null;
23
- };
24
- type Warning = { path: string; message: string };
25
-
26
- const contentOf = (text: string) => text.replace(/(?:\r\n|\r|\n)$/, '');
27
- function fenceOf(content: string) {
28
- const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(content);
29
- return match?.[1]
30
- ? { marker: match[1].charAt(0), length: match[1].length, closing: !match[2]?.trim() }
31
- : null;
20
+ heading: boolean;
21
+ number: number;
22
+ text: string;
23
+ }
24
+ interface Warning {
25
+ message: string;
26
+ path: string;
32
27
  }
33
- const headingOf = (content: string) => /^\s{0,3}#{1,6}(?:\s|$)/.test(content);
34
28
 
35
- function sourceLines(text: string) {
29
+ const contentOf = function contentOf(text: string) {
30
+ return text.replace(/(?:\r\n|\r|\n)$/u, '');
31
+ };
32
+ const fenceOf = function fenceOf(content: string) {
33
+ const indentationMatch = /^ */u.exec(content);
34
+ const indentation = indentationMatch?.[0].length ?? 0;
35
+ if (indentation > 3) {
36
+ return null;
37
+ }
38
+ const source = content.slice(indentation);
39
+ const marker = source.at(0);
40
+ if (marker !== '`' && marker !== '~') {
41
+ return null;
42
+ }
43
+ const markerMatch = /^(?:`+|~+)/u.exec(source);
44
+ const markerRun = markerMatch?.[0] ?? '';
45
+ if (markerRun.length < 3) {
46
+ return null;
47
+ }
48
+ return {
49
+ closing: source.slice(markerRun.length).trim() === '',
50
+ length: markerRun.length,
51
+ marker,
52
+ };
53
+ };
54
+ const isHeading = (content: string) => /^\s{0,3}#{1,6}(?:\s|$)/u.test(content);
55
+
56
+ const sourceLines = function sourceLines(text: string) {
36
57
  return rawMarkdownLines(text)
37
58
  .filter((line) => line !== '')
38
59
  .map((line, index) => {
39
60
  const content = contentOf(line);
40
61
  return {
41
- number: index + 1,
42
- text: line,
43
- bytes: Buffer.byteLength(line, 'utf8'),
44
62
  blank: content.trim() === '',
45
- heading: headingOf(content),
63
+ bytes: Buffer.byteLength(line, 'utf-8'),
46
64
  fence: fenceOf(content),
65
+ heading: isHeading(content),
66
+ number: index + 1,
67
+ text: line,
47
68
  };
48
69
  });
49
- }
70
+ };
50
71
 
51
- function blocksFor(document: Document, warnings: Warning[]) {
72
+ const blocksFor = function blocksFor(document: Document, warnings: Warning[]) {
52
73
  const blocks: SourceLine[][] = [];
53
74
  let block: SourceLine[] = [];
54
75
  let activeFence: SourceLine['fence'] = null;
55
76
  const flush = () => {
56
- if (block.length) blocks.push(block);
77
+ if (block.length > 0) {
78
+ blocks.push(block);
79
+ }
57
80
  block = [];
58
81
  };
59
82
 
60
83
  for (const line of sourceLines(document.text)) {
61
- const inFence = activeFence !== null;
62
- const closingFence =
63
- activeFence &&
64
- line.fence?.closing &&
84
+ const isInFence = activeFence !== null;
85
+ const isSameFence =
86
+ activeFence !== null &&
87
+ line.fence !== null &&
65
88
  line.fence.marker === activeFence.marker &&
66
89
  line.fence.length >= activeFence.length;
67
- if (closingFence) activeFence = null;
68
- else if (!activeFence) activeFence = line.fence;
69
- if (line.bytes > MAX_BYTES) {
90
+ const isClosingFence = line.fence?.closing === true && isSameFence;
91
+ if (isClosingFence) {
92
+ activeFence = null;
93
+ } else {
94
+ activeFence ??= line.fence;
95
+ }
96
+ if (line.bytes > maxBytes) {
70
97
  flush();
71
98
  warnings.push({
99
+ message: `Line ${line.number} is ${line.bytes} UTF-8 bytes, exceeding the ${maxBytes}-byte limit; omitted as unread.`,
72
100
  path: document.path,
73
- message: `Line ${line.number} is ${line.bytes} UTF-8 bytes, exceeding the ${MAX_BYTES}-byte limit; omitted as unread.`,
74
101
  });
75
102
  continue;
76
103
  }
77
- if (!inFence && line.heading) flush();
104
+ if (!isInFence && line.heading) {
105
+ flush();
106
+ }
78
107
  block.push(line);
79
- if (!activeFence && (line.blank || closingFence)) flush();
108
+ const shouldFlush = activeFence === null && (line.blank || isClosingFence);
109
+ if (shouldFlush) {
110
+ flush();
111
+ }
80
112
  }
81
113
  flush();
82
114
  return blocks;
83
- }
115
+ };
84
116
 
85
- function splitBlock(block: SourceLine[]) {
117
+ const splitBlock = function splitBlock(block: SourceLine[]) {
86
118
  const pieces: SourceLine[][] = [];
87
119
  let piece: SourceLine[] = [];
88
120
  let bytes = 0;
89
121
  for (const line of block) {
90
- if (piece.length && bytes + line.bytes > MAX_BYTES) {
122
+ if (piece.length > 0 && bytes + line.bytes > maxBytes) {
91
123
  pieces.push(piece);
92
124
  piece = [];
93
125
  bytes = 0;
@@ -95,61 +127,72 @@ function splitBlock(block: SourceLine[]) {
95
127
  piece.push(line);
96
128
  bytes += line.bytes;
97
129
  }
98
- if (piece.length) pieces.push(piece);
130
+ if (piece.length > 0) {
131
+ pieces.push(piece);
132
+ }
99
133
  return pieces;
100
- }
134
+ };
101
135
 
102
- function packedBlocks(blocks: SourceLine[][]) {
136
+ const packedBlocks = function packedBlocks(blocks: SourceLine[][]) {
103
137
  const packed: SourceLine[][] = [];
104
138
  let current: SourceLine[] = [];
105
139
  let bytes = 0;
106
140
  const flush = () => {
107
- if (current.length) packed.push(current);
141
+ if (current.length > 0) {
142
+ packed.push(current);
143
+ }
108
144
  current = [];
109
145
  bytes = 0;
110
146
  };
111
147
 
112
- for (const block of blocks.flatMap((item) => splitBlock(item))) {
148
+ const splitBlocks = blocks.flatMap((item) => splitBlock(item));
149
+ for (const block of splitBlocks) {
113
150
  const first = block.at(0);
114
- if (!first) continue;
151
+ if (first === undefined) {
152
+ continue;
153
+ }
115
154
  const blockBytes = block.reduce((total, line) => total + line.bytes, 0);
116
155
  const last = current.at(-1);
117
- if (
118
- current.length &&
119
- (bytes + blockBytes > MAX_BYTES || !last || last.number + 1 !== first.number)
120
- )
156
+ const shouldFlush =
157
+ last === undefined || last.number + 1 !== first.number || bytes + blockBytes > maxBytes;
158
+ if (shouldFlush && current.length > 0) {
121
159
  flush();
160
+ }
122
161
  current.push(...block);
123
162
  bytes += blockBytes;
124
163
  }
125
164
  flush();
126
165
  return packed;
127
- }
166
+ };
128
167
 
129
- function makeUnit(document: Document, lines: SourceLine[]): IngestionUnit {
168
+ const makeUnit = function makeUnit(document: Document, lines: SourceLine[]): IngestionUnit {
130
169
  const first = lines.at(0);
131
170
  const last = lines.at(-1);
132
- if (!first || !last) throw new Error('Cannot create an empty ingestion unit');
171
+ if (first === undefined || last === undefined) {
172
+ throw new Error('Cannot create an empty ingestion unit');
173
+ }
133
174
  const text = lines.map((line) => line.text).join('');
134
175
  return {
135
- id: `${document.path}:${first.number}-${last.number}`,
136
176
  document: document.id,
137
177
  hash: digest(text),
138
- lineStart: first.number,
178
+ id: `${document.path}:${first.number}-${last.number}`,
139
179
  lineEnd: last.number,
180
+ lineStart: first.number,
140
181
  text,
141
182
  };
142
- }
183
+ };
143
184
 
144
- export function ingestionUnits(documents: Document[]): {
185
+ export const ingestionUnits = function ingestionUnits(documents: Document[]): {
145
186
  units: IngestionUnit[];
146
187
  warnings: Warning[];
147
188
  } {
148
189
  const units: IngestionUnit[] = [];
149
190
  const warnings: Warning[] = [];
150
- for (const document of documents)
151
- units.push(
152
- ...packedBlocks(blocksFor(document, warnings)).map((lines) => makeUnit(document, lines)),
191
+ for (const document of documents) {
192
+ const documentUnits = packedBlocks(blocksFor(document, warnings)).map((lines) =>
193
+ makeUnit(document, lines)
153
194
  );
195
+ units.push(...documentUnits);
196
+ }
154
197
  return { units, warnings };
155
- }
198
+ };