@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/documents.ts CHANGED
@@ -1,12 +1,17 @@
1
- import { rawMarkdownLines, lineContent } from './markdown.ts';
2
1
  import { lstatSync, readFileSync, readdirSync } from 'node:fs';
3
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
2
+ import pathModule from 'node:path';
4
3
  import { parseArgs } from 'node:util';
5
- import { TextDecoder } from 'node:util';
4
+ import { compareSerializedStrings } from './ordering.ts';
6
5
  import { HivexError } from './errors.ts';
7
- import { describeMarkdown, hash, isMarkdownPath } from './markdown.ts';
8
-
9
- export type Document = {
6
+ import {
7
+ describeMarkdown,
8
+ hash,
9
+ isMarkdownPath,
10
+ lineContent,
11
+ rawMarkdownLines,
12
+ } from './markdown.ts';
13
+
14
+ export interface Document {
10
15
  id: string;
11
16
  path: string;
12
17
  title: string;
@@ -15,9 +20,9 @@ export type Document = {
15
20
  status: string | null;
16
21
  links: string[];
17
22
  historical: boolean;
18
- };
23
+ }
19
24
 
20
- export type Project = {
25
+ export interface Project {
21
26
  root: string;
22
27
  snapshot: string;
23
28
  currentSnapshot: string;
@@ -25,576 +30,753 @@ export type Project = {
25
30
  currentDocuments: Document[];
26
31
  historicalDocuments: Document[];
27
32
  warnings: { path: string; message: string }[];
28
- };
33
+ }
29
34
 
30
- type Config = { include: string[]; exclude: string[]; history: string[] };
31
- type Candidate = { absolutePath: string; path: string };
32
- type ParsedDocument = Document & { rawLinks: string[] };
33
- type CommandOptions = {
35
+ interface Config {
36
+ include: string[];
37
+ exclude: string[];
38
+ history: string[];
39
+ }
40
+ interface Candidate {
41
+ absolutePath: string;
42
+ path: string;
43
+ }
44
+ interface ParsedDocument {
45
+ document: Document;
46
+ rawLinks: string[];
47
+ }
48
+ interface CommandOptions {
34
49
  root: string;
35
50
  maxBytes: number;
36
- from: number | undefined;
37
- to: number | undefined;
51
+ from?: number;
52
+ to?: number;
38
53
  limit: number;
39
54
  cursor?: string;
40
- };
41
- type ParsedValues = {
55
+ }
56
+ interface ParsedValues {
57
+ [key: string]: string | undefined;
42
58
  root?: string;
43
- 'max-bytes'?: string;
44
- limit?: string;
45
59
  cursor?: string;
46
60
  from?: string;
61
+ limit?: string;
47
62
  to?: string;
48
- };
49
-
50
- const DEFAULT_INCLUDE = ['**/*.md', '**/*.markdown', '**/*.mdown'];
51
- const DEFAULT_MAX_BYTES = 16_384;
52
- const MAX_OUTPUT_BYTES = 65_536;
53
- const MAX_SOURCE_BYTES = 32 * 1024 * 1024;
54
- const MAX_CORPUS_BYTES = 64 * 1024 * 1024;
55
- const MAX_DOCUMENTS = 2_048;
56
- const MAX_PATTERNS = 64;
57
- const ORIGIN = 'current-worktree';
58
- const PROTECTED_DIRECTORIES = new Set(['.git', '.hivex', 'node_modules']);
59
- const EXCLUDED_DIRECTORIES = new Set(['vendor', 'dist', 'build']);
63
+ }
64
+ interface CollectionContext {
65
+ config: Config;
66
+ root: string;
67
+ warnings: Project['warnings'];
68
+ }
69
+ interface ContinuationOptions {
70
+ lineEnd: number;
71
+ maxBytes: number;
72
+ requestedEnd: number;
73
+ totalLines: number;
74
+ }
75
+
76
+ const defaultInclude = ['**/*.md', '**/*.markdown', '**/*.mdown'];
77
+ const defaultMaxBytes = 16_384;
78
+ const maxOutputBytes = 65_536;
79
+ const maxSourceBytes = 32 * 1024 * 1024;
80
+ const maxCorpusBytes = 64 * 1024 * 1024;
81
+ const maxDocuments = 2048;
82
+ const maxPatterns = 64;
83
+ const origin = 'current-worktree';
84
+ const protectedDirectories = new Set(['.git', '.hivex', 'node_modules']);
85
+ const excludedDirectories = new Set(['vendor', 'dist', 'build']);
60
86
  const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
61
87
 
62
- function fail(code: string, message: string, details?: Record<string, unknown>): never {
63
- throw new HivexError({ code, message, details });
64
- }
88
+ const fail = function fail(
89
+ code: string,
90
+ message: string,
91
+ details?: Record<string, unknown>
92
+ ): never {
93
+ throw new HivexError({ code, details, message });
94
+ };
65
95
 
66
- function pathFor(root: string, absolutePath: string) {
67
- return relative(root, absolutePath).split('\\').join('/');
68
- }
96
+ const pathFor = function pathFor(root: string, absolutePath: string) {
97
+ return pathModule.relative(root, absolutePath).replaceAll('\\', '/');
98
+ };
69
99
 
70
- function decodeUtf8(bytes: Buffer, path: string, limit: number) {
71
- if (bytes.byteLength > limit)
100
+ const decodeUtf8 = function decodeUtf8(bytes: Buffer, sourcePath: string, limit: number) {
101
+ if (bytes.byteLength > limit) {
72
102
  fail('DOCUMENT_TOO_LARGE', `Markdown source exceeds ${limit} bytes`, {
73
- path,
74
103
  actualBytes: bytes.byteLength,
75
104
  maxBytes: limit,
105
+ path: sourcePath,
76
106
  });
107
+ }
77
108
  try {
78
109
  return decoder.decode(bytes);
79
110
  } catch {
80
- fail('INVALID_UTF8', 'Markdown source is not valid UTF-8', { path });
111
+ return fail('INVALID_UTF8', 'Markdown source is not valid UTF-8', {
112
+ path: sourcePath,
113
+ });
81
114
  }
82
- }
115
+ };
83
116
 
84
- function readUtf8(absolutePath: string, path: string, limit: number) {
117
+ const readUtf8 = function readUtf8(absolutePath: string, sourcePath: string, limit: number) {
85
118
  try {
86
- return decodeUtf8(readFileSync(absolutePath), path, limit);
119
+ return decodeUtf8(readFileSync(absolutePath), sourcePath, limit);
87
120
  } catch (error) {
88
- if (error instanceof HivexError) throw error;
89
- fail('SOURCE_READ_FAILED', 'Unable to read Markdown source', {
90
- path,
91
- reason: error instanceof Error ? error.message : 'unknown read failure',
121
+ if (error instanceof HivexError) {
122
+ throw error;
123
+ }
124
+ return fail('SOURCE_READ_FAILED', 'Unable to read Markdown source', {
125
+ path: sourcePath,
126
+ reason: Error.isError(error) ? error.message : 'unknown read failure',
92
127
  });
93
128
  }
94
- }
129
+ };
95
130
 
96
- function validatePattern(pattern: unknown, field: string, index: number) {
97
- if (typeof pattern !== 'string' || !pattern.trim())
98
- fail('INVALID_CONFIG', `${field}[${index}] must be a non-empty relative glob`);
131
+ const validatePattern = function validatePattern(pattern: unknown, field: string, index: number) {
132
+ if (typeof pattern !== 'string' || !pattern.trim()) {
133
+ return fail('INVALID_CONFIG', `${field}[${index}] must be a non-empty relative glob`);
134
+ }
99
135
  const normalized = pattern.replaceAll('\\', '/');
100
136
  const segments = normalized.split('/');
101
137
  if (
102
- isAbsolute(normalized) ||
138
+ pathModule.isAbsolute(normalized) ||
103
139
  normalized.startsWith('/') ||
104
- normalized.includes('\0') ||
140
+ normalized.includes('\u{0}') ||
105
141
  segments.includes('..')
106
- )
107
- fail('INVALID_CONFIG', `${field}[${index}] must stay inside the project root`);
142
+ ) {
143
+ return fail('INVALID_CONFIG', `${field}[${index}] must stay inside the project root`);
144
+ }
108
145
  try {
109
- new Bun.Glob(normalized);
146
+ const glob = new Bun.Glob(normalized);
147
+ glob.match('');
110
148
  } catch (error) {
111
- fail('INVALID_CONFIG', `${field}[${index}] is not a valid glob`, {
112
- reason: error instanceof Error ? error.message : 'invalid glob',
149
+ return fail('INVALID_CONFIG', `${field}[${index}] is not a valid glob`, {
150
+ reason: Error.isError(error) ? error.message : 'invalid glob',
113
151
  });
114
152
  }
115
153
  return normalized;
116
- }
154
+ };
117
155
 
118
- function patterns(value: unknown, field: string, fallback: string[]) {
119
- if (value === undefined) return [...fallback];
120
- if (!Array.isArray(value) || value.length > MAX_PATTERNS)
121
- fail('INVALID_CONFIG', `${field} must contain at most ${MAX_PATTERNS} relative globs`);
156
+ const patterns = function patterns(value: unknown, field: string, fallback: string[]) {
157
+ if (value === undefined) {
158
+ return [...fallback];
159
+ }
160
+ if (!Array.isArray(value) || value.length > maxPatterns) {
161
+ return fail('INVALID_CONFIG', `${field} must contain at most ${maxPatterns} relative globs`);
162
+ }
122
163
  return value.map((pattern, index) => validatePattern(pattern, field, index));
123
- }
164
+ };
124
165
 
125
- function configText(root: string) {
126
- const path = join(root, 'hivex.json');
166
+ const readConfigBytes = function readConfigBytes(configPath: string) {
167
+ const stat = lstatSync(configPath);
168
+ if (stat.isSymbolicLink()) {
169
+ return fail('INVALID_CONFIG', 'hivex.json must not be a symlink');
170
+ }
171
+ if (!stat.isFile()) {
172
+ return fail('INVALID_CONFIG', 'hivex.json must be a regular file');
173
+ }
174
+ return readFileSync(configPath);
175
+ };
176
+
177
+ const configText = function configText(root: string) {
178
+ const configPath = pathModule.join(root, 'hivex.json');
127
179
  let bytes: Buffer;
128
180
  try {
129
- const stat = lstatSync(path);
130
- if (stat.isSymbolicLink()) fail('INVALID_CONFIG', 'hivex.json must not be a symlink');
131
- if (!stat.isFile()) fail('INVALID_CONFIG', 'hivex.json must be a regular file');
132
- bytes = readFileSync(path);
181
+ bytes = readConfigBytes(configPath);
133
182
  } catch (error) {
134
- if (error instanceof HivexError) throw error;
135
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
136
- fail('INVALID_CONFIG', 'Unable to read hivex.json', {
137
- reason: error instanceof Error ? error.message : 'unknown read failure',
183
+ if (error instanceof HivexError) {
184
+ throw error;
185
+ }
186
+ if (Error.isError(error) && 'code' in error && error.code === 'ENOENT') {
187
+ return null;
188
+ }
189
+ return fail('INVALID_CONFIG', 'Unable to read hivex.json', {
190
+ reason: Error.isError(error) ? error.message : 'unknown read failure',
138
191
  });
139
192
  }
140
193
  return decodeUtf8(bytes, 'hivex.json', 64 * 1024);
141
- }
194
+ };
142
195
 
143
- function parseConfig(text: string): Config {
196
+ const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
197
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
198
+ };
199
+
200
+ const parseConfig = function parseConfig(text: string): Config {
144
201
  let value: unknown;
145
202
  try {
146
- value = JSON.parse(text.replace(/^\uFEFF/u, ''));
203
+ value = JSON.parse(text.replace(/^\u{FEFF}/u, ''));
147
204
  } catch (error) {
148
- fail('INVALID_CONFIG', 'hivex.json must contain valid JSON', {
149
- reason: error instanceof Error ? error.message : 'invalid JSON',
205
+ return fail('INVALID_CONFIG', 'hivex.json must contain valid JSON', {
206
+ reason: Error.isError(error) ? error.message : 'invalid JSON',
150
207
  });
151
208
  }
152
- if (!value || typeof value !== 'object' || Array.isArray(value))
153
- fail('INVALID_CONFIG', 'hivex.json must contain an object');
154
- const record = value as Record<string, unknown>;
155
- if ('collections' in record)
156
- fail(
209
+ if (!isRecord(value)) {
210
+ return fail('INVALID_CONFIG', 'hivex.json must contain an object');
211
+ }
212
+ if ('collections' in value) {
213
+ return fail(
157
214
  'LEGACY_CONFIGURATION',
158
- 'hivex.json uses legacy collections; replace it with include and exclude globs',
215
+ 'hivex.json uses legacy collections; replace it with include and exclude globs'
159
216
  );
217
+ }
218
+ const record = value;
160
219
  const unknown = Object.keys(record).filter(
161
- (key) => !['include', 'exclude', 'history'].includes(key),
220
+ (key) => !['exclude', 'history', 'include'].includes(key)
162
221
  );
163
- if (unknown.length) fail('INVALID_CONFIG', `hivex.json has unsupported field: ${unknown[0]}`);
222
+ if (unknown.length) {
223
+ return fail('INVALID_CONFIG', `hivex.json has unsupported field: ${unknown[0]}`);
224
+ }
164
225
  return {
165
- include: patterns(record.include, 'include', DEFAULT_INCLUDE),
166
- exclude: patterns(record.exclude, 'exclude', []),
167
- history: patterns(record.history, 'history', []),
226
+ exclude: patterns(value.exclude, 'exclude', []),
227
+ history: patterns(value.history, 'history', []),
228
+ include: patterns(value.include, 'include', defaultInclude),
168
229
  };
169
- }
230
+ };
170
231
 
171
- function configFrom(root: string): Config {
232
+ const configFrom = function configFrom(root: string): Config {
172
233
  const text = configText(root);
173
- if (text === null) return { include: [...DEFAULT_INCLUDE], exclude: [], history: [] };
234
+ if (text === null) {
235
+ return { exclude: [], history: [], include: [...defaultInclude] };
236
+ }
174
237
  return parseConfig(text);
175
- }
238
+ };
239
+
240
+ const isExcludedName = function isExcludedName(name: string, config: Config) {
241
+ if (protectedDirectories.has(name)) {
242
+ return true;
243
+ }
244
+ if (!excludedDirectories.has(name) && !name.startsWith('.')) {
245
+ return false;
246
+ }
247
+ return [...config.include, ...config.history].every(
248
+ (pattern) => !pattern.split('/').includes(name)
249
+ );
250
+ };
176
251
 
177
- function excludedName(name: string, config: Config) {
178
- if (PROTECTED_DIRECTORIES.has(name)) return true;
179
- if (!EXCLUDED_DIRECTORIES.has(name) && !name.startsWith('.')) return false;
180
- return ![...config.include, ...config.history].some((pattern) =>
181
- pattern.split('/').includes(name),
252
+ const isMatch = function isMatch(pathName: string, patternsToMatch: string[]) {
253
+ return patternsToMatch.some((pattern) => {
254
+ const glob = new Bun.Glob(pattern);
255
+ return glob.match(pathName);
256
+ });
257
+ };
258
+
259
+ const isExcludedSubtree = function isExcludedSubtree(pathName: string, config: Config) {
260
+ const subtrees = config.exclude.filter(
261
+ (pattern) => pattern.endsWith('/**') && !pattern.startsWith('!')
182
262
  );
183
- }
263
+ return isMatch(`${pathName}/`, subtrees);
264
+ };
184
265
 
185
- function collectCandidates(
186
- root: string,
187
- current: string,
188
- config: Config,
189
- warnings: Project['warnings'],
190
- ) {
266
+ const collectCandidates = function collectCandidates(current: string, context: CollectionContext) {
267
+ const { config, root, warnings } = context;
191
268
  const candidates: Candidate[] = [];
192
269
  let entries;
193
270
  try {
194
- entries = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
195
- left.name.localeCompare(right.name),
271
+ entries = readdirSync(current, { withFileTypes: true }).toSorted((left, right) =>
272
+ left.name.localeCompare(right.name)
196
273
  );
197
274
  } catch (error) {
198
275
  warnings.push({
276
+ message: `Unable to inspect directory: ${Error.isError(error) ? error.message : 'unknown error'}`,
199
277
  path: pathFor(root, current) || '.',
200
- message: `Unable to inspect directory: ${error instanceof Error ? error.message : 'unknown error'}`,
201
278
  });
202
279
  return candidates;
203
280
  }
204
281
 
205
282
  for (const entry of entries) {
206
- const absolutePath = join(current, entry.name);
207
- const path = pathFor(root, absolutePath);
208
- if (excludedName(entry.name, config)) continue;
209
- if (entry.isSymbolicLink()) {
210
- warnings.push({ path, message: 'Skipped symbolic link' });
283
+ if (isExcludedName(entry.name, config)) {
211
284
  continue;
212
285
  }
213
- if (entry.isDirectory()) {
214
- candidates.push(...collectCandidates(root, absolutePath, config, warnings));
215
- continue;
286
+ const absolutePath = pathModule.join(current, entry.name);
287
+ const path = pathFor(root, absolutePath);
288
+ if (entry.isSymbolicLink()) {
289
+ warnings.push({ message: 'Skipped symbolic link', path });
290
+ } else if (entry.isDirectory()) {
291
+ if (!isExcludedSubtree(path, config)) {
292
+ candidates.push(...collectCandidates(absolutePath, context));
293
+ }
294
+ } else if (entry.isFile()) {
295
+ candidates.push({ absolutePath, path });
216
296
  }
217
- if (entry.isFile()) candidates.push({ absolutePath, path });
218
297
  }
219
298
  return candidates;
220
- }
221
-
222
- function matches(path: string, patternsToMatch: string[]) {
223
- return patternsToMatch.some((pattern) => new Bun.Glob(pattern).match(path));
224
- }
299
+ };
225
300
 
226
- function selected(candidates: Candidate[], config: Config) {
301
+ const selected = function selected(candidates: Candidate[], config: Config) {
227
302
  const available = candidates
228
- .filter(({ path }) => isMarkdownPath(path))
229
- .filter(({ path }) => !matches(path, config.exclude))
230
- .sort((left, right) => left.path.localeCompare(right.path));
303
+ .filter(({ path: pathName }) => isMarkdownPath(pathName))
304
+ .filter(({ path: pathName }) => !isMatch(pathName, config.exclude))
305
+ .toSorted((left, right) => left.path.localeCompare(right.path));
231
306
  return {
232
307
  current: available.filter(
233
- ({ path }) => matches(path, config.include) && !matches(path, config.history),
308
+ ({ path: pathName }) =>
309
+ isMatch(pathName, config.include) && !isMatch(pathName, config.history)
234
310
  ),
235
- historical: available.filter(({ path }) => matches(path, config.history)),
311
+ historical: available.filter(({ path: pathName }) => isMatch(pathName, config.history)),
236
312
  };
237
- }
313
+ };
238
314
 
239
- function parseCandidate(candidate: Candidate, historical: boolean): ParsedDocument {
240
- const text = readUtf8(candidate.absolutePath, candidate.path, MAX_SOURCE_BYTES);
315
+ const parseCandidate = function parseCandidate(
316
+ candidate: Candidate,
317
+ isHistorical: boolean
318
+ ): ParsedDocument {
319
+ const text = readUtf8(candidate.absolutePath, candidate.path, maxSourceBytes);
241
320
  const source = describeMarkdown(candidate.path, text);
242
321
  return {
243
- id: candidate.path,
244
- path: candidate.path,
245
- title: source.title,
246
- text,
247
- hash: hash(text),
248
- status: source.status,
249
- links: [],
250
- historical,
322
+ document: {
323
+ hash: hash(text),
324
+ historical: isHistorical,
325
+ id: candidate.path,
326
+ links: [],
327
+ path: candidate.path,
328
+ status: source.status,
329
+ text,
330
+ title: source.title,
331
+ },
251
332
  rawLinks: source.links,
252
333
  };
253
- }
334
+ };
254
335
 
255
- function warningFor(path: string, error: unknown) {
336
+ const warningFor = function warningFor(path: string, error: unknown) {
256
337
  return {
338
+ message: Error.isError(error) ? error.message : 'Unable to parse Markdown source',
257
339
  path,
258
- message: error instanceof Error ? error.message : 'Unable to parse Markdown source',
259
340
  };
260
- }
341
+ };
261
342
 
262
- function linkPath(root: string, source: Document, rawLink: string) {
263
- if (!rawLink || rawLink.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(rawLink)) return null;
264
- const fragment = rawLink.search(/[?#]/);
343
+ const linkPath = function linkPath(root: string, source: Document, rawLink: string) {
344
+ if (!rawLink || rawLink.startsWith('#') || /^[a-z][a-z0-9+.-]*:/iu.test(rawLink)) {
345
+ return null;
346
+ }
347
+ const fragment = rawLink.search(/[?#]/u);
265
348
  const target = fragment === -1 ? rawLink : rawLink.slice(0, fragment);
266
- if (!target) return null;
349
+ if (!target) {
350
+ return null;
351
+ }
267
352
  let decoded: string;
268
353
  try {
269
354
  decoded = decodeURIComponent(target);
270
355
  } catch {
271
356
  return null;
272
357
  }
273
- const absoluteTarget = resolve(dirname(join(root, source.path)), decoded);
358
+ const absoluteTarget = pathModule.resolve(
359
+ pathModule.dirname(pathModule.join(root, source.path)),
360
+ decoded
361
+ );
274
362
  const relativeTarget = pathFor(root, absoluteTarget);
275
- if (
276
- !relativeTarget ||
277
- relativeTarget === '.' ||
278
- relativeTarget.startsWith('../') ||
279
- isAbsolute(relativeTarget) ||
280
- !isMarkdownPath(relativeTarget)
281
- )
363
+ if (!relativeTarget || relativeTarget === '.') {
364
+ return null;
365
+ }
366
+ if (relativeTarget.startsWith('../') || pathModule.isAbsolute(relativeTarget)) {
367
+ return null;
368
+ }
369
+ if (!isMarkdownPath(relativeTarget)) {
282
370
  return null;
371
+ }
283
372
  return relativeTarget;
284
- }
373
+ };
285
374
 
286
- function resolveLinks(root: string, documents: ParsedDocument[]) {
287
- for (const document of documents) {
375
+ const resolveLinks = function resolveLinks(root: string, documents: ParsedDocument[]) {
376
+ for (const { document, rawLinks } of documents) {
288
377
  const links = new Set<string>();
289
- for (const rawLink of document.rawLinks) {
378
+ for (const rawLink of rawLinks) {
290
379
  const link = linkPath(root, document, rawLink);
291
- if (link) links.add(link);
380
+ if (link !== null) {
381
+ links.add(link);
382
+ }
292
383
  }
293
384
  document.links = [...links];
294
385
  }
295
- }
386
+ };
296
387
 
297
- function snapshotFor(documents: Document[], config: Config) {
388
+ const snapshotFor = function snapshotFor(documents: Document[], config: Config) {
298
389
  const identities = documents
299
390
  .map((document) => `${document.id}\0${document.hash}`)
300
- .sort()
391
+ .toSorted(compareSerializedStrings)
301
392
  .join('\n');
302
- const selection = JSON.stringify({
303
- include: [...config.include].sort(),
304
- exclude: [...config.exclude].sort(),
305
- history: [...config.history].sort(),
306
- ignoredDirectories: [...PROTECTED_DIRECTORIES, ...EXCLUDED_DIRECTORIES].sort(),
307
- markdownExtensions: ['.md', '.markdown', '.mdown'],
308
- });
393
+ const selection = JSON.stringify(
394
+ Object.fromEntries([
395
+ ['include', [...config.include].toSorted(compareSerializedStrings)],
396
+ ['exclude', [...config.exclude].toSorted(compareSerializedStrings)],
397
+ ['history', [...config.history].toSorted(compareSerializedStrings)],
398
+ [
399
+ 'ignoredDirectories',
400
+ [...protectedDirectories, ...excludedDirectories].toSorted(compareSerializedStrings),
401
+ ],
402
+ ['markdownExtensions', ['.md', '.markdown', '.mdown']],
403
+ ])
404
+ );
309
405
  return hash(`${identities}\nselection\0${selection}`);
310
- }
406
+ };
311
407
 
312
- function absoluteRoot(root: string) {
313
- if (!root.trim()) fail('INVALID_ROOT', 'Project root must be a non-empty path');
314
- const requested = resolve(root);
408
+ const validateRoot = function validateRoot(requested: string) {
409
+ const stat = lstatSync(requested);
410
+ if (stat.isSymbolicLink()) {
411
+ return fail('INVALID_ROOT', 'Project root must not be a symlink');
412
+ }
413
+ if (!stat.isDirectory()) {
414
+ return fail('INVALID_ROOT', 'Project root must be a directory');
415
+ }
416
+ return requested;
417
+ };
418
+
419
+ const absoluteRoot = function absoluteRoot(root: string) {
420
+ if (!root.trim()) {
421
+ fail('INVALID_ROOT', 'Project root must be a non-empty path');
422
+ }
423
+ const requested = pathModule.resolve(root);
315
424
  try {
316
- const stat = lstatSync(requested);
317
- if (stat.isSymbolicLink()) fail('INVALID_ROOT', 'Project root must not be a symlink');
318
- if (!stat.isDirectory()) fail('INVALID_ROOT', 'Project root must be a directory');
319
- return requested;
425
+ return validateRoot(requested);
320
426
  } catch (error) {
321
- if (error instanceof HivexError) throw error;
322
- fail('INVALID_ROOT', 'Project root is not readable', {
427
+ if (error instanceof HivexError) {
428
+ throw error;
429
+ }
430
+ return fail('INVALID_ROOT', 'Project root is not readable', {
431
+ reason: Error.isError(error) ? error.message : 'unknown root failure',
323
432
  root: requested,
324
- reason: error instanceof Error ? error.message : 'unknown root failure',
325
433
  });
326
434
  }
327
- }
435
+ };
436
+
437
+ const parseCandidateWithinBudget = function parseCandidateWithinBudget(
438
+ candidate: Candidate,
439
+ isHistorical: boolean,
440
+ sourceBytes: number
441
+ ) {
442
+ if (sourceBytes + lstatSync(candidate.absolutePath).size > maxCorpusBytes) {
443
+ return fail(
444
+ 'CORPUS_LIMIT',
445
+ 'Selected Markdown exceeds the 64 MiB memory budget; narrow include paths'
446
+ );
447
+ }
448
+ return parseCandidate(candidate, isHistorical);
449
+ };
328
450
 
329
- export function loadProject(root: string): Project {
451
+ export const loadProject = function loadProject(root: string): Project {
330
452
  const projectRoot = absoluteRoot(root);
331
453
  const config = configFrom(projectRoot);
332
454
  const warnings: Project['warnings'] = [];
333
- const candidates = collectCandidates(projectRoot, projectRoot, config, warnings);
455
+ const candidates = collectCandidates(projectRoot, {
456
+ config,
457
+ root: projectRoot,
458
+ warnings,
459
+ });
334
460
  const selection = selected(candidates, config);
335
461
  const historicalPaths = new Set(selection.historical.map((candidate) => candidate.path));
336
462
  const selectedCandidates = [...selection.current, ...selection.historical];
337
463
  const parsed: ParsedDocument[] = [];
338
464
  let sourceBytes = 0;
339
- for (const candidate of selectedCandidates.slice(0, MAX_DOCUMENTS)) {
465
+ for (const candidate of selectedCandidates.slice(0, maxDocuments)) {
340
466
  try {
341
- if (sourceBytes + lstatSync(candidate.absolutePath).size > MAX_CORPUS_BYTES)
342
- fail(
343
- 'CORPUS_LIMIT',
344
- 'Selected Markdown exceeds the 64 MiB memory budget; narrow include paths',
345
- );
346
- const document = parseCandidate(candidate, historicalPaths.has(candidate.path));
347
- sourceBytes += Buffer.byteLength(document.text);
348
- parsed.push(document);
467
+ const parsedDocument = parseCandidateWithinBudget(
468
+ candidate,
469
+ historicalPaths.has(candidate.path),
470
+ sourceBytes
471
+ );
472
+ sourceBytes += Buffer.byteLength(parsedDocument.document.text);
473
+ parsed.push(parsedDocument);
349
474
  } catch (error) {
350
475
  warnings.push(warningFor(candidate.path, error));
351
476
  }
352
477
  }
353
- if (selectedCandidates.length > MAX_DOCUMENTS)
478
+ if (selectedCandidates.length > maxDocuments) {
354
479
  warnings.push({
480
+ message: `Only the first ${maxDocuments} Markdown sources were loaded`,
355
481
  path: '.',
356
- message: `Only the first ${MAX_DOCUMENTS} Markdown sources were loaded`,
357
482
  });
483
+ }
358
484
  resolveLinks(projectRoot, parsed);
359
485
  const documents = parsed
360
- .map(({ rawLinks: _rawLinks, ...document }) => document)
361
- .sort((left, right) => left.path.localeCompare(right.path));
486
+ .map(({ document }) => document)
487
+ .toSorted((left, right) => left.path.localeCompare(right.path));
362
488
  return {
363
- root: projectRoot,
364
- snapshot: snapshotFor(documents, config),
489
+ currentDocuments: documents.filter((document) => !document.historical),
365
490
  currentSnapshot: snapshotFor(
366
491
  documents.filter((document) => !document.historical),
367
- config,
492
+ config
368
493
  ),
369
494
  documents,
370
- currentDocuments: documents.filter((document) => !document.historical),
371
495
  historicalDocuments: documents.filter((document) => document.historical),
496
+ root: projectRoot,
497
+ snapshot: snapshotFor(documents, config),
372
498
  warnings,
373
499
  };
374
- }
500
+ };
375
501
 
376
- function positiveInteger(value: string | undefined, label: string, fallback?: number) {
502
+ const positiveInteger = function positiveInteger(
503
+ value: string | undefined,
504
+ label: string,
505
+ fallback?: number
506
+ ) {
377
507
  if (value === undefined) {
378
- if (fallback !== undefined) return fallback;
379
- fail('INVALID_ARGUMENT', `${label} is required`);
508
+ if (fallback !== undefined) {
509
+ return fallback;
510
+ }
511
+ return fail('INVALID_ARGUMENT', `${label} is required`);
512
+ }
513
+ if (!/^\d+$/u.test(value)) {
514
+ return fail('INVALID_ARGUMENT', `${label} must be a positive integer`);
380
515
  }
381
- if (!/^[0-9]+$/.test(value)) fail('INVALID_ARGUMENT', `${label} must be a positive integer`);
382
516
  const number = Number(value);
383
- if (!Number.isSafeInteger(number) || number < 1)
384
- fail('INVALID_ARGUMENT', `${label} must be positive`);
517
+ if (!Number.isSafeInteger(number) || number < 1) {
518
+ return fail('INVALID_ARGUMENT', `${label} must be positive`);
519
+ }
385
520
  return number;
386
- }
521
+ };
387
522
 
388
- function optionalPositiveInteger(value: string | undefined, label: string) {
389
- if (value === undefined) return undefined;
390
- return positiveInteger(value, label);
391
- }
523
+ const optionalPositiveInteger = function optionalPositiveInteger(
524
+ value: string | undefined,
525
+ label: string
526
+ ) {
527
+ let result: number | undefined;
528
+ if (value !== undefined) {
529
+ result = positiveInteger(value, label);
530
+ }
531
+ return result;
532
+ };
392
533
 
393
- function parseCommandArgs(args: string[]) {
394
- let parsed: ReturnType<typeof parseArgs>;
534
+ const parseCommandArguments = function parseCommandArguments(commandArguments: string[]) {
395
535
  try {
396
- parsed = parseArgs({
397
- args,
536
+ return parseArgs({
398
537
  allowPositionals: true,
399
- strict: true,
538
+ args: commandArguments,
400
539
  options: {
401
- root: { type: 'string' },
402
- 'max-bytes': { type: 'string' },
403
- limit: { type: 'string' },
404
540
  cursor: { type: 'string' },
405
541
  from: { type: 'string' },
542
+ limit: { type: 'string' },
543
+ 'max-bytes': { type: 'string' },
544
+ root: { type: 'string' },
406
545
  to: { type: 'string' },
407
546
  },
547
+ strict: true,
408
548
  });
409
549
  } catch (error) {
410
- fail('INVALID_ARGUMENT', error instanceof Error ? error.message : 'Invalid command arguments');
550
+ return fail(
551
+ 'INVALID_ARGUMENT',
552
+ Error.isError(error) ? error.message : 'Invalid command arguments'
553
+ );
411
554
  }
412
- return parsed;
413
- }
555
+ };
414
556
 
415
- function validatePositionals(
557
+ const validatePositionals: (
416
558
  command: string | undefined,
417
559
  id: string | undefined,
418
- extra: string | undefined,
560
+ extra: string | undefined
561
+ ) => asserts command is 'sources' | 'read' = function validatePositionals(
562
+ command: string | undefined,
563
+ id: string | undefined,
564
+ extra: string | undefined
419
565
  ): asserts command is 'sources' | 'read' {
420
- if (command !== 'sources' && command !== 'read')
566
+ if (command !== 'sources' && command !== 'read') {
421
567
  fail('INVALID_ARGUMENT', 'Usage: hivex sources | read <id> [options]');
422
- if (command === 'sources' && (id !== undefined || extra !== undefined))
568
+ }
569
+ if (command === 'sources' && (id !== undefined || extra !== undefined)) {
423
570
  fail('INVALID_ARGUMENT', 'sources does not accept a source id');
424
- if (command === 'read' && id === undefined) fail('INVALID_ARGUMENT', 'read requires a source id');
425
- if (command === 'read' && extra !== undefined)
571
+ }
572
+ if (command === 'read' && id === undefined) {
573
+ fail('INVALID_ARGUMENT', 'read requires a source id');
574
+ }
575
+ if (command === 'read' && extra !== undefined) {
426
576
  fail('INVALID_ARGUMENT', 'read accepts one source id');
427
- }
577
+ }
578
+ };
428
579
 
429
- function rangeOptions(command: string, values: ParsedValues) {
580
+ const rangeOptions = function rangeOptions(command: string, values: ParsedValues) {
430
581
  if (command === 'sources') {
431
- if (values.from !== undefined || values.to !== undefined)
582
+ if (values.from !== undefined || values.to !== undefined) {
432
583
  fail('INVALID_ARGUMENT', '--from and --to are only valid for read');
433
- return { from: undefined, to: undefined };
584
+ }
585
+ return {};
434
586
  }
435
- if (values.limit !== undefined || values.cursor !== undefined)
587
+ if (values.limit !== undefined || values.cursor !== undefined) {
436
588
  fail('INVALID_ARGUMENT', '--limit and --cursor are only valid for sources');
589
+ }
437
590
  return {
438
591
  from: optionalPositiveInteger(values.from, '--from'),
439
592
  to: optionalPositiveInteger(values.to, '--to'),
440
593
  };
441
- }
594
+ };
442
595
 
443
- function commandOptions(args: string[]): {
596
+ const commandOptions = function commandOptions(commandArguments: string[]): {
444
597
  command: string;
445
598
  id: string | undefined;
446
599
  options: CommandOptions;
447
600
  } {
448
- const parsed = parseCommandArgs(args);
601
+ const parsed = parseCommandArguments(commandArguments);
449
602
  const values = parsed.values as ParsedValues;
450
603
  const [command, id, extra] = parsed.positionals;
451
604
  validatePositionals(command, id, extra);
452
- const maxBytes = positiveInteger(values['max-bytes'], '--max-bytes', DEFAULT_MAX_BYTES);
453
- if (maxBytes > MAX_OUTPUT_BYTES)
454
- fail('INVALID_ARGUMENT', `--max-bytes must be at most ${MAX_OUTPUT_BYTES}`);
605
+ const maxBytes = positiveInteger(values['max-bytes'], '--max-bytes', defaultMaxBytes);
606
+ if (maxBytes > maxOutputBytes) {
607
+ fail('INVALID_ARGUMENT', `--max-bytes must be at most ${maxOutputBytes}`);
608
+ }
455
609
  return {
456
610
  command,
457
611
  id,
458
612
  options: {
459
- root: values.root ?? process.cwd(),
460
- maxBytes,
461
- limit: positiveInteger(values.limit, '--limit', 20),
462
- cursor: values.cursor,
463
613
  ...rangeOptions(command, values),
614
+ cursor: values.cursor,
615
+ limit: positiveInteger(values.limit, '--limit', 20),
616
+ maxBytes,
617
+ root: values.root ?? process.cwd(),
464
618
  },
465
619
  };
466
- }
620
+ };
467
621
 
468
- function metadata(document: Document) {
469
- const { text: _text, ...result } = document;
622
+ const metadata = function metadata(document: Document) {
623
+ const result = { ...document };
624
+ Reflect.deleteProperty(result, 'text');
470
625
  return result;
471
- }
626
+ };
472
627
 
473
- function linesFor(text: string) {
628
+ const linesFor = function linesFor(text: string) {
474
629
  return { lines: rawMarkdownLines(text) };
475
- }
630
+ };
476
631
 
477
- function boundedLines(window: { lines: string[]; start: number; end: number; maxBytes: number }) {
632
+ const boundedLines = function boundedLines(window: {
633
+ lines: string[];
634
+ start: number;
635
+ end: number;
636
+ maxBytes: number;
637
+ }) {
478
638
  const { lines, start, end, maxBytes } = window;
479
639
  let text = '';
480
640
  let prefix = '';
481
641
  let lineEnd = start - 1;
482
- for (let line = start; line <= end; line++) {
642
+ for (let line = start; line <= end; line += 1) {
483
643
  const raw = lines[line - 1] ?? '';
484
644
  const current = line === lines.length ? raw : lineContent(raw);
485
645
  const next = prefix + current;
486
646
  if (Buffer.byteLength(next) > maxBytes) {
487
- if (lineEnd < start)
647
+ if (lineEnd < start) {
488
648
  fail('OUTPUT_LIMIT', 'The first requested line exceeds --max-bytes', {
489
649
  line,
490
650
  maxBytes,
491
651
  requiredBytes: Buffer.byteLength(next),
492
652
  });
493
- return { text, lineEnd };
653
+ }
654
+ return { lineEnd, text };
494
655
  }
495
656
  text = next;
496
657
  prefix += raw;
497
658
  lineEnd = line;
498
659
  }
499
- return { text, lineEnd };
500
- }
660
+ return { lineEnd, text };
661
+ };
501
662
 
502
- function continuationFor(
503
- lineEnd: number,
504
- totalLines: number,
505
- requestedEnd: number,
506
- maxBytes: number,
507
- ) {
508
- if (lineEnd >= totalLines) return null;
509
- let reason = 'range';
510
- if (lineEnd < requestedEnd) reason = 'max-bytes';
663
+ const continuationFor = function continuationFor({
664
+ lineEnd,
665
+ maxBytes,
666
+ requestedEnd,
667
+ totalLines,
668
+ }: ContinuationOptions) {
669
+ if (lineEnd >= totalLines) {
670
+ return null;
671
+ }
672
+ const reason = lineEnd < requestedEnd ? 'max-bytes' : 'range';
511
673
  return {
512
674
  from: lineEnd + 1,
513
- to: totalLines,
514
- reason,
515
675
  maxBytes,
676
+ reason,
677
+ to: totalLines,
516
678
  };
517
- }
679
+ };
518
680
 
519
- function readCommand(project: Project, id: string, options: CommandOptions) {
681
+ const readCommand = function readCommand(project: Project, id: string, options: CommandOptions) {
520
682
  const source = project.documents.find((document) => document.id === id);
521
- if (!source) fail('SOURCE_NOT_FOUND', `Markdown source was not selected: ${id}`, { id });
683
+ if (!source) {
684
+ return fail('SOURCE_NOT_FOUND', `Markdown source was not selected: ${id}`, {
685
+ id,
686
+ });
687
+ }
522
688
  const { lines } = linesFor(source.text);
523
689
  const start = options.from ?? 1;
524
690
  const requestedEnd = options.to ?? lines.length;
525
- if (start > lines.length || requestedEnd > lines.length || start > requestedEnd)
691
+ if (start > lines.length || requestedEnd > lines.length || start > requestedEnd) {
526
692
  fail('INVALID_RANGE', `Line range ${start}-${requestedEnd} is outside the source`, {
527
693
  id,
528
694
  lineCount: lines.length,
529
695
  });
696
+ }
530
697
  const bounded = boundedLines({
531
- lines,
532
- start,
533
698
  end: requestedEnd,
699
+ lines,
534
700
  maxBytes: options.maxBytes,
701
+ start,
535
702
  });
536
- const continuation = continuationFor(
537
- bounded.lineEnd,
538
- lines.length,
703
+ const continuation = continuationFor({
704
+ lineEnd: bounded.lineEnd,
705
+ maxBytes: options.maxBytes,
539
706
  requestedEnd,
540
- options.maxBytes,
541
- );
707
+ totalLines: lines.length,
708
+ });
542
709
  return {
543
710
  command: 'read',
544
- origin: ORIGIN,
711
+ continuation,
712
+ lineEnd: bounded.lineEnd,
713
+ lineStart: start,
714
+ origin,
545
715
  snapshot: project.snapshot,
546
716
  source: metadata(source),
547
717
  text: bounded.text,
548
- lineStart: start,
549
- lineEnd: bounded.lineEnd,
550
- continuation,
551
718
  truncated: continuation !== null,
552
719
  warnings: project.warnings,
553
720
  };
554
- }
721
+ };
555
722
 
556
- function listSources(project: Project, options: CommandOptions) {
557
- const cursor = options.cursor?.match(/^s1\.([a-f0-9]{64})\.([0-9]+)$/);
558
- if (options.cursor !== undefined && (!cursor || cursor[1] !== project.snapshot))
723
+ const listSources = function listSources(project: Project, options: CommandOptions) {
724
+ const cursor = options.cursor?.match(/^s1\.(?<snapshot>[a-f\d]{64})\.(?<start>\d+)$/u);
725
+ const cursorSnapshot = cursor?.groups?.snapshot;
726
+ if (options.cursor !== undefined && cursorSnapshot !== project.snapshot) {
559
727
  fail('INVALID_CURSOR', 'Source continuation belongs to a different or invalid snapshot');
560
- const start = Number(cursor?.[2] ?? 0);
561
- if (!Number.isSafeInteger(start) || start < 0 || (start > 0 && start >= project.documents.length))
728
+ }
729
+ const start = Number(cursor?.groups?.start ?? 0);
730
+ if (
731
+ !Number.isSafeInteger(start) ||
732
+ start < 0 ||
733
+ (start > 0 && start >= project.documents.length)
734
+ ) {
562
735
  fail('INVALID_CURSOR', 'Source continuation is outside this snapshot');
736
+ }
563
737
  const documents: ReturnType<typeof metadata>[] = [];
564
- const response = () => ({
565
- command: 'sources',
566
- origin: ORIGIN,
567
- snapshot: project.snapshot,
568
- documents,
569
- totalDocuments: project.documents.length,
570
- continuation:
571
- start + documents.length < project.documents.length
572
- ? `s1.${project.snapshot}.${start + documents.length}`
573
- : null,
574
- warnings: project.warnings,
575
- });
576
- for (const document of project.documents.slice(
738
+ const response = function response() {
739
+ return {
740
+ command: 'sources',
741
+ continuation:
742
+ start + documents.length < project.documents.length
743
+ ? `s1.${project.snapshot}.${start + documents.length}`
744
+ : null,
745
+ documents,
746
+ origin,
747
+ snapshot: project.snapshot,
748
+ totalDocuments: project.documents.length,
749
+ warnings: project.warnings,
750
+ };
751
+ };
752
+ const selectedDocuments = project.documents.slice(
577
753
  start,
578
- start + Math.min(options.limit, MAX_DOCUMENTS),
579
- )) {
754
+ start + Math.min(options.limit, maxDocuments)
755
+ );
756
+ for (const document of selectedDocuments) {
580
757
  documents.push(metadata(document));
581
- if (Buffer.byteLength(JSON.stringify(response())) <= options.maxBytes) continue;
582
- documents.pop();
583
- if (!documents.length)
584
- fail(
585
- 'OUTPUT_LIMIT',
586
- 'The next source metadata does not fit; increase --max-bytes or narrow the selected sources',
587
- );
588
- break;
758
+ if (Buffer.byteLength(JSON.stringify(response())) > options.maxBytes) {
759
+ documents.pop();
760
+ if (!documents.length) {
761
+ fail(
762
+ 'OUTPUT_LIMIT',
763
+ 'The next source metadata does not fit; increase --max-bytes or narrow the selected sources'
764
+ );
765
+ }
766
+ break;
767
+ }
589
768
  }
590
- if (Buffer.byteLength(JSON.stringify(response())) > options.maxBytes)
769
+ if (Buffer.byteLength(JSON.stringify(response())) > options.maxBytes) {
591
770
  fail('OUTPUT_LIMIT', 'Source-list metadata exceeds --max-bytes');
771
+ }
592
772
  return response();
593
- }
773
+ };
594
774
 
595
- export function documentCommand(args: string[]): unknown {
596
- const { command, id, options } = commandOptions(args);
775
+ export const documentCommand = function documentCommand(commandArguments: string[]): unknown {
776
+ const { command, id, options } = commandOptions(commandArguments);
597
777
  const project = loadProject(options.root);
598
- if (command === 'sources') return listSources(project, options);
778
+ if (command === 'sources') {
779
+ return listSources(project, options);
780
+ }
599
781
  return readCommand(project, id ?? '', options);
600
- }
782
+ };