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