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