@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/README.md +47 -163
- package/docs/CONTEXT.md +18 -36
- package/docs/README.md +4 -11
- package/docs/adr/0003-independent-bun-installation.md +5 -19
- package/docs/adr/0010-practical-knowledge-assistance.md +28 -81
- package/docs/adr/0011-shared-knowledge-and-selective-history.md +12 -43
- package/docs/engineering.md +46 -142
- package/package.json +30 -10
- package/skills/hivex/SKILL.md +24 -92
- package/skills/hivex/references/markdown.md +12 -42
- package/src/cli/diagnostic.ts +21 -11
- package/src/cli.ts +45 -35
- package/src/documents.ts +502 -320
- package/src/errors.ts +8 -6
- package/src/implementation.ts +185 -87
- package/src/ingestion-units.ts +107 -64
- package/src/knowledge-maintenance.ts +35 -22
- package/src/knowledge-model.ts +386 -268
- package/src/knowledge-serialization.ts +239 -0
- package/src/knowledge-snapshot.ts +100 -77
- package/src/knowledge-store.ts +634 -453
- package/src/knowledge.ts +1001 -758
- package/src/markdown.ts +107 -45
- package/src/model/connection.ts +134 -76
- package/src/model/failure.ts +46 -23
- package/src/model/invoke.ts +346 -166
- package/src/model/profile.ts +201 -103
- package/src/model/rpc-error.ts +21 -0
- package/src/model/server.ts +151 -82
- package/src/model/thread.ts +24 -14
- package/src/model/transcript.ts +87 -46
- package/src/ordering.ts +9 -0
- package/src/retrieval/lexical.ts +64 -41
- package/src/review.ts +83 -55
- package/src/runtime.d.ts +4 -0
- package/src/snapshot-command.ts +53 -39
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
|
|
2
|
+
import pathModule from 'node:path';
|
|
4
3
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import {
|
|
4
|
+
import { compareSerializedStrings } from './ordering.ts';
|
|
6
5
|
import { HivexError } from './errors.ts';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
to
|
|
51
|
+
from?: number;
|
|
52
|
+
to?: number;
|
|
38
53
|
limit: number;
|
|
39
54
|
cursor?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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).
|
|
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,
|
|
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', {
|
|
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,
|
|
117
|
+
const readUtf8 = function readUtf8(absolutePath: string, sourcePath: string, limit: number) {
|
|
85
118
|
try {
|
|
86
|
-
return decodeUtf8(readFileSync(absolutePath),
|
|
119
|
+
return decodeUtf8(readFileSync(absolutePath), sourcePath, limit);
|
|
87
120
|
} catch (error) {
|
|
88
|
-
if (error instanceof HivexError)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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)
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
126
|
-
const
|
|
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
|
-
|
|
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)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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(/^\
|
|
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
|
|
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 (!
|
|
153
|
-
fail('INVALID_CONFIG', 'hivex.json must contain an object');
|
|
154
|
-
|
|
155
|
-
if ('collections' in
|
|
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) => !['
|
|
220
|
+
(key) => !['exclude', 'history', 'include'].includes(key)
|
|
162
221
|
);
|
|
163
|
-
if (unknown.length)
|
|
222
|
+
if (unknown.length) {
|
|
223
|
+
return fail('INVALID_CONFIG', `hivex.json has unsupported field: ${unknown[0]}`);
|
|
224
|
+
}
|
|
164
225
|
return {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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('!')
|
|
182
262
|
);
|
|
183
|
-
}
|
|
263
|
+
return isMatch(`${pathName}/`, subtrees);
|
|
264
|
+
};
|
|
184
265
|
|
|
185
|
-
function collectCandidates(
|
|
186
|
-
root
|
|
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 }).
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
229
|
-
.filter(({ path }) => !
|
|
230
|
-
.
|
|
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 }) =>
|
|
308
|
+
({ path: pathName }) =>
|
|
309
|
+
isMatch(pathName, config.include) && !isMatch(pathName, config.history)
|
|
234
310
|
),
|
|
235
|
-
historical: available.filter(({ path }) =>
|
|
311
|
+
historical: available.filter(({ path: pathName }) => isMatch(pathName, config.history)),
|
|
236
312
|
};
|
|
237
|
-
}
|
|
313
|
+
};
|
|
238
314
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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+.-]*:/
|
|
264
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
378
|
+
for (const rawLink of rawLinks) {
|
|
290
379
|
const link = linkPath(root, document, rawLink);
|
|
291
|
-
if (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
|
-
.
|
|
391
|
+
.toSorted(compareSerializedStrings)
|
|
301
392
|
.join('\n');
|
|
302
|
-
const selection = JSON.stringify(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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)
|
|
322
|
-
|
|
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,
|
|
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,
|
|
465
|
+
for (const candidate of selectedCandidates.slice(0, maxDocuments)) {
|
|
340
466
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 >
|
|
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(({
|
|
361
|
-
.
|
|
486
|
+
.map(({ document }) => document)
|
|
487
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
362
488
|
return {
|
|
363
|
-
|
|
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
|
-
|
|
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)
|
|
379
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
394
|
-
let parsed: ReturnType<typeof parseArgs>;
|
|
534
|
+
const parseCommandArguments = function parseCommandArguments(commandArguments: string[]) {
|
|
395
535
|
try {
|
|
396
|
-
|
|
397
|
-
args,
|
|
536
|
+
return parseArgs({
|
|
398
537
|
allowPositionals: true,
|
|
399
|
-
|
|
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(
|
|
550
|
+
return fail(
|
|
551
|
+
'INVALID_ARGUMENT',
|
|
552
|
+
Error.isError(error) ? error.message : 'Invalid command arguments'
|
|
553
|
+
);
|
|
411
554
|
}
|
|
412
|
-
|
|
413
|
-
}
|
|
555
|
+
};
|
|
414
556
|
|
|
415
|
-
|
|
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
|
-
|
|
568
|
+
}
|
|
569
|
+
if (command === 'sources' && (id !== undefined || extra !== undefined)) {
|
|
423
570
|
fail('INVALID_ARGUMENT', 'sources does not accept a source id');
|
|
424
|
-
|
|
425
|
-
if (command === 'read' &&
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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',
|
|
453
|
-
if (maxBytes >
|
|
454
|
-
fail('INVALID_ARGUMENT', `--max-bytes must be at most ${
|
|
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
|
|
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: {
|
|
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
|
-
|
|
653
|
+
}
|
|
654
|
+
return { lineEnd, text };
|
|
494
655
|
}
|
|
495
656
|
text = next;
|
|
496
657
|
prefix += raw;
|
|
497
658
|
lineEnd = line;
|
|
498
659
|
}
|
|
499
|
-
return {
|
|
500
|
-
}
|
|
660
|
+
return { lineEnd, text };
|
|
661
|
+
};
|
|
501
662
|
|
|
502
|
-
function continuationFor(
|
|
503
|
-
lineEnd
|
|
504
|
-
|
|
505
|
-
requestedEnd
|
|
506
|
-
|
|
507
|
-
) {
|
|
508
|
-
if (lineEnd >= totalLines)
|
|
509
|
-
|
|
510
|
-
|
|
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)
|
|
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
|
-
|
|
703
|
+
const continuation = continuationFor({
|
|
704
|
+
lineEnd: bounded.lineEnd,
|
|
705
|
+
maxBytes: options.maxBytes,
|
|
539
706
|
requestedEnd,
|
|
540
|
-
|
|
541
|
-
);
|
|
707
|
+
totalLines: lines.length,
|
|
708
|
+
});
|
|
542
709
|
return {
|
|
543
710
|
command: 'read',
|
|
544
|
-
|
|
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-
|
|
558
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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,
|
|
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()))
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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(
|
|
596
|
-
const { command, id, options } = commandOptions(
|
|
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')
|
|
778
|
+
if (command === 'sources') {
|
|
779
|
+
return listSources(project, options);
|
|
780
|
+
}
|
|
599
781
|
return readCommand(project, id ?? '', options);
|
|
600
|
-
}
|
|
782
|
+
};
|