@h1v35/hivex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,575 @@
1
+ import { rawMarkdownLines, lineContent } from './markdown.ts';
2
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { TextDecoder } from 'node:util';
6
+ import { HivexError } from './errors.ts';
7
+ import { describeMarkdown, hash, isMarkdownPath } from './markdown.ts';
8
+
9
+ export type Document = {
10
+ id: string;
11
+ path: string;
12
+ title: string;
13
+ text: string;
14
+ hash: string;
15
+ status: string | null;
16
+ links: string[];
17
+ };
18
+
19
+ export type Project = {
20
+ root: string;
21
+ snapshot: string;
22
+ documents: Document[];
23
+ warnings: { path: string; message: string }[];
24
+ };
25
+
26
+ type Config = { include: string[]; exclude: string[] };
27
+ type Candidate = { absolutePath: string; path: string };
28
+ type ParsedDocument = Document & { rawLinks: string[] };
29
+ type CommandOptions = {
30
+ root: string;
31
+ maxBytes: number;
32
+ from: number | undefined;
33
+ to: number | undefined;
34
+ limit: number;
35
+ cursor?: string;
36
+ };
37
+ type ParsedValues = {
38
+ root?: string;
39
+ 'max-bytes'?: string;
40
+ limit?: string;
41
+ cursor?: string;
42
+ from?: string;
43
+ 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']);
56
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
57
+
58
+ function fail(code: string, message: string, details?: Record<string, unknown>): never {
59
+ throw new HivexError({ code, message, details });
60
+ }
61
+
62
+ function pathFor(root: string, absolutePath: string) {
63
+ return relative(root, absolutePath).split('\\').join('/');
64
+ }
65
+
66
+ function decodeUtf8(bytes: Buffer, path: string, limit: number) {
67
+ if (bytes.byteLength > limit)
68
+ fail('DOCUMENT_TOO_LARGE', `Markdown source exceeds ${limit} bytes`, {
69
+ path,
70
+ actualBytes: bytes.byteLength,
71
+ maxBytes: limit,
72
+ });
73
+ try {
74
+ return decoder.decode(bytes);
75
+ } catch {
76
+ fail('INVALID_UTF8', 'Markdown source is not valid UTF-8', { path });
77
+ }
78
+ }
79
+
80
+ function readUtf8(absolutePath: string, path: string, limit: number) {
81
+ try {
82
+ return decodeUtf8(readFileSync(absolutePath), path, limit);
83
+ } 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',
88
+ });
89
+ }
90
+ }
91
+
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`);
95
+ const normalized = pattern.replaceAll('\\', '/');
96
+ const segments = normalized.split('/');
97
+ if (
98
+ isAbsolute(normalized) ||
99
+ normalized.startsWith('/') ||
100
+ normalized.includes('\0') ||
101
+ segments.includes('..')
102
+ )
103
+ fail('INVALID_CONFIG', `${field}[${index}] must stay inside the project root`);
104
+ try {
105
+ new Bun.Glob(normalized);
106
+ } catch (error) {
107
+ fail('INVALID_CONFIG', `${field}[${index}] is not a valid glob`, {
108
+ reason: error instanceof Error ? error.message : 'invalid glob',
109
+ });
110
+ }
111
+ return normalized;
112
+ }
113
+
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`);
118
+ return value.map((pattern, index) => validatePattern(pattern, field, index));
119
+ }
120
+
121
+ function configText(root: string) {
122
+ const path = join(root, 'hivex.json');
123
+ let bytes: Buffer;
124
+ 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);
129
+ } 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',
134
+ });
135
+ }
136
+ return decodeUtf8(bytes, 'hivex.json', 64 * 1024);
137
+ }
138
+
139
+ function parseConfig(text: string): Config {
140
+ let value: unknown;
141
+ try {
142
+ value = JSON.parse(text.replace(/^\uFEFF/u, ''));
143
+ } catch (error) {
144
+ fail('INVALID_CONFIG', 'hivex.json must contain valid JSON', {
145
+ reason: error instanceof Error ? error.message : 'invalid JSON',
146
+ });
147
+ }
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(
153
+ 'LEGACY_CONFIGURATION',
154
+ 'hivex.json uses legacy collections; replace it with include and exclude globs',
155
+ );
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]}`);
158
+ return {
159
+ include: patterns(record.include, 'include', DEFAULT_INCLUDE),
160
+ exclude: patterns(record.exclude, 'exclude', []),
161
+ };
162
+ }
163
+
164
+ function configFrom(root: string): Config {
165
+ const text = configText(root);
166
+ if (text === null) return { include: [...DEFAULT_INCLUDE], exclude: [] };
167
+ return parseConfig(text);
168
+ }
169
+
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
+ }
175
+
176
+ function collectCandidates(
177
+ root: string,
178
+ current: string,
179
+ config: Config,
180
+ warnings: Project['warnings'],
181
+ ) {
182
+ const candidates: Candidate[] = [];
183
+ let entries;
184
+ try {
185
+ entries = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
186
+ left.name.localeCompare(right.name),
187
+ );
188
+ } catch (error) {
189
+ warnings.push({
190
+ path: pathFor(root, current) || '.',
191
+ message: `Unable to inspect directory: ${error instanceof Error ? error.message : 'unknown error'}`,
192
+ });
193
+ return candidates;
194
+ }
195
+
196
+ 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' });
202
+ continue;
203
+ }
204
+ if (entry.isDirectory()) {
205
+ candidates.push(...collectCandidates(root, absolutePath, config, warnings));
206
+ continue;
207
+ }
208
+ if (entry.isFile()) candidates.push({ absolutePath, path });
209
+ }
210
+ return candidates;
211
+ }
212
+
213
+ function matches(path: string, patternsToMatch: string[]) {
214
+ return patternsToMatch.some((pattern) => new Bun.Glob(pattern).match(path));
215
+ }
216
+
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
+ }
224
+
225
+ function parseCandidate(candidate: Candidate): ParsedDocument {
226
+ const text = readUtf8(candidate.absolutePath, candidate.path, MAX_SOURCE_BYTES);
227
+ const source = describeMarkdown(candidate.path, text);
228
+ 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: [],
236
+ rawLinks: source.links,
237
+ };
238
+ }
239
+
240
+ function warningFor(path: string, error: unknown) {
241
+ return {
242
+ path,
243
+ message: error instanceof Error ? error.message : 'Unable to parse Markdown source',
244
+ };
245
+ }
246
+
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(/[?#]/);
250
+ const target = fragment === -1 ? rawLink : rawLink.slice(0, fragment);
251
+ if (!target) return null;
252
+ let decoded: string;
253
+ try {
254
+ decoded = decodeURIComponent(target);
255
+ } catch {
256
+ return null;
257
+ }
258
+ const absoluteTarget = resolve(dirname(join(root, source.path)), decoded);
259
+ const relativeTarget = pathFor(root, absoluteTarget);
260
+ if (
261
+ !relativeTarget ||
262
+ relativeTarget === '.' ||
263
+ relativeTarget.startsWith('../') ||
264
+ isAbsolute(relativeTarget) ||
265
+ !ids.has(relativeTarget)
266
+ )
267
+ return null;
268
+ return relativeTarget;
269
+ }
270
+
271
+ function resolveLinks(root: string, documents: ParsedDocument[]) {
272
+ const ids = new Set(documents.map((document) => document.id));
273
+ for (const document of documents) {
274
+ 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);
278
+ }
279
+ document.links = [...links];
280
+ }
281
+ }
282
+
283
+ function snapshotFor(documents: Document[], config: Config) {
284
+ const identities = documents
285
+ .map((document) => `${document.id}\0${document.hash}`)
286
+ .sort()
287
+ .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
+ });
294
+ return hash(`${identities}\nselection\0${selection}`);
295
+ }
296
+
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);
300
+ 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;
305
+ } catch (error) {
306
+ if (error instanceof HivexError) throw error;
307
+ fail('INVALID_ROOT', 'Project root is not readable', {
308
+ root: requested,
309
+ reason: error instanceof Error ? error.message : 'unknown root failure',
310
+ });
311
+ }
312
+ }
313
+
314
+ export function loadProject(root: string): Project {
315
+ const projectRoot = absoluteRoot(root);
316
+ const config = configFrom(projectRoot);
317
+ const warnings: Project['warnings'] = [];
318
+ const candidates = collectCandidates(projectRoot, projectRoot, config, warnings);
319
+ const selectedCandidates = selected(candidates, config);
320
+ const parsed: ParsedDocument[] = [];
321
+ let sourceBytes = 0;
322
+ for (const candidate of selectedCandidates.slice(0, MAX_DOCUMENTS)) {
323
+ 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);
332
+ } catch (error) {
333
+ warnings.push(warningFor(candidate.path, error));
334
+ }
335
+ }
336
+ if (selectedCandidates.length > MAX_DOCUMENTS)
337
+ warnings.push({
338
+ path: '.',
339
+ message: `Only the first ${MAX_DOCUMENTS} Markdown sources were loaded`,
340
+ });
341
+ resolveLinks(projectRoot, parsed);
342
+ const documents = parsed.map(({ rawLinks: _rawLinks, ...document }) => document);
343
+ return {
344
+ root: projectRoot,
345
+ snapshot: snapshotFor(documents, config),
346
+ documents,
347
+ warnings,
348
+ };
349
+ }
350
+
351
+ function positiveInteger(value: string | undefined, label: string, fallback?: number) {
352
+ if (value === undefined) {
353
+ if (fallback !== undefined) return fallback;
354
+ fail('INVALID_ARGUMENT', `${label} is required`);
355
+ }
356
+ if (!/^[0-9]+$/.test(value)) fail('INVALID_ARGUMENT', `${label} must be a positive integer`);
357
+ const number = Number(value);
358
+ if (!Number.isSafeInteger(number) || number < 1)
359
+ fail('INVALID_ARGUMENT', `${label} must be positive`);
360
+ return number;
361
+ }
362
+
363
+ function optionalPositiveInteger(value: string | undefined, label: string) {
364
+ if (value === undefined) return undefined;
365
+ return positiveInteger(value, label);
366
+ }
367
+
368
+ function parseCommandArgs(args: string[]) {
369
+ let parsed: ReturnType<typeof parseArgs>;
370
+ try {
371
+ parsed = parseArgs({
372
+ args,
373
+ allowPositionals: true,
374
+ strict: true,
375
+ options: {
376
+ root: { type: 'string' },
377
+ 'max-bytes': { type: 'string' },
378
+ limit: { type: 'string' },
379
+ cursor: { type: 'string' },
380
+ from: { type: 'string' },
381
+ to: { type: 'string' },
382
+ },
383
+ });
384
+ } catch (error) {
385
+ fail('INVALID_ARGUMENT', error instanceof Error ? error.message : 'Invalid command arguments');
386
+ }
387
+ return parsed;
388
+ }
389
+
390
+ function validatePositionals(
391
+ command: string | undefined,
392
+ id: string | undefined,
393
+ extra: string | undefined,
394
+ ): asserts command is 'sources' | 'read' {
395
+ if (command !== 'sources' && command !== 'read')
396
+ fail('INVALID_ARGUMENT', 'Usage: hivex sources | read <id> [options]');
397
+ if (command === 'sources' && (id !== undefined || extra !== undefined))
398
+ 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)
401
+ fail('INVALID_ARGUMENT', 'read accepts one source id');
402
+ }
403
+
404
+ function rangeOptions(command: string, values: ParsedValues) {
405
+ if (command === 'sources') {
406
+ if (values.from !== undefined || values.to !== undefined)
407
+ fail('INVALID_ARGUMENT', '--from and --to are only valid for read');
408
+ return { from: undefined, to: undefined };
409
+ }
410
+ if (values.limit !== undefined || values.cursor !== undefined)
411
+ fail('INVALID_ARGUMENT', '--limit and --cursor are only valid for sources');
412
+ return {
413
+ from: optionalPositiveInteger(values.from, '--from'),
414
+ to: optionalPositiveInteger(values.to, '--to'),
415
+ };
416
+ }
417
+
418
+ function commandOptions(args: string[]): {
419
+ command: string;
420
+ id: string | undefined;
421
+ options: CommandOptions;
422
+ } {
423
+ const parsed = parseCommandArgs(args);
424
+ const values = parsed.values as ParsedValues;
425
+ const [command, id, extra] = parsed.positionals;
426
+ 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}`);
430
+ return {
431
+ command,
432
+ id,
433
+ options: {
434
+ root: values.root ?? process.cwd(),
435
+ maxBytes,
436
+ limit: positiveInteger(values.limit, '--limit', 20),
437
+ cursor: values.cursor,
438
+ ...rangeOptions(command, values),
439
+ },
440
+ };
441
+ }
442
+
443
+ function metadata(document: Document) {
444
+ const { text: _text, ...result } = document;
445
+ return result;
446
+ }
447
+
448
+ function linesFor(text: string) {
449
+ return { lines: rawMarkdownLines(text) };
450
+ }
451
+
452
+ function boundedLines(window: { lines: string[]; start: number; end: number; maxBytes: number }) {
453
+ const { lines, start, end, maxBytes } = window;
454
+ let text = '';
455
+ let prefix = '';
456
+ let lineEnd = start - 1;
457
+ for (let line = start; line <= end; line++) {
458
+ const raw = lines[line - 1] ?? '';
459
+ const current = line === lines.length ? raw : lineContent(raw);
460
+ const next = prefix + current;
461
+ if (Buffer.byteLength(next) > maxBytes) {
462
+ if (lineEnd < start)
463
+ fail('OUTPUT_LIMIT', 'The first requested line exceeds --max-bytes', {
464
+ line,
465
+ maxBytes,
466
+ requiredBytes: Buffer.byteLength(next),
467
+ });
468
+ return { text, lineEnd };
469
+ }
470
+ text = next;
471
+ prefix += raw;
472
+ lineEnd = line;
473
+ }
474
+ return { text, lineEnd };
475
+ }
476
+
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';
486
+ return {
487
+ from: lineEnd + 1,
488
+ to: totalLines,
489
+ reason,
490
+ maxBytes,
491
+ };
492
+ }
493
+
494
+ function readCommand(project: Project, id: string, options: CommandOptions) {
495
+ const source = project.documents.find((document) => document.id === id);
496
+ if (!source) fail('SOURCE_NOT_FOUND', `Markdown source was not selected: ${id}`, { id });
497
+ const { lines } = linesFor(source.text);
498
+ const start = options.from ?? 1;
499
+ const requestedEnd = options.to ?? lines.length;
500
+ if (start > lines.length || requestedEnd > lines.length || start > requestedEnd)
501
+ fail('INVALID_RANGE', `Line range ${start}-${requestedEnd} is outside the source`, {
502
+ id,
503
+ lineCount: lines.length,
504
+ });
505
+ const bounded = boundedLines({
506
+ lines,
507
+ start,
508
+ end: requestedEnd,
509
+ maxBytes: options.maxBytes,
510
+ });
511
+ const continuation = continuationFor(
512
+ bounded.lineEnd,
513
+ lines.length,
514
+ requestedEnd,
515
+ options.maxBytes,
516
+ );
517
+ return {
518
+ command: 'read',
519
+ origin: ORIGIN,
520
+ snapshot: project.snapshot,
521
+ source: metadata(source),
522
+ text: bounded.text,
523
+ lineStart: start,
524
+ lineEnd: bounded.lineEnd,
525
+ continuation,
526
+ truncated: continuation !== null,
527
+ warnings: project.warnings,
528
+ };
529
+ }
530
+
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))
534
+ 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))
537
+ fail('INVALID_CURSOR', 'Source continuation is outside this snapshot');
538
+ 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(
552
+ start,
553
+ start + Math.min(options.limit, MAX_DOCUMENTS),
554
+ )) {
555
+ 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;
564
+ }
565
+ if (Buffer.byteLength(JSON.stringify(response())) > options.maxBytes)
566
+ fail('OUTPUT_LIMIT', 'Source-list metadata exceeds --max-bytes');
567
+ return response();
568
+ }
569
+
570
+ export function documentCommand(args: string[]): unknown {
571
+ const { command, id, options } = commandOptions(args);
572
+ const project = loadProject(options.root);
573
+ if (command === 'sources') return listSources(project, options);
574
+ return readCommand(project, id ?? '', options);
575
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ export class HivexError extends Error {
2
+ readonly code: string;
3
+ readonly details: Readonly<Record<string, unknown>> | undefined;
4
+
5
+ constructor(options: {
6
+ code: string;
7
+ message: string;
8
+ details?: Readonly<Record<string, unknown>>;
9
+ }) {
10
+ super(options.message);
11
+ this.name = 'HivexError';
12
+ this.code = options.code;
13
+ this.details = options.details;
14
+ }
15
+ }