@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,418 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { rawMarkdownLines, sourceRange } from './markdown.ts';
4
+ import type { Document, Project } from './documents.ts';
5
+
6
+ export const digest = (value: string | Uint8Array) =>
7
+ createHash('sha256').update(value).digest('hex');
8
+ const explanation = z.string().min(1).max(2048);
9
+ export const citationSchema = z.object({
10
+ document: z.string().min(1),
11
+ lineStart: z.number().int().positive(),
12
+ lineEnd: z.number().int().positive(),
13
+ });
14
+ export type SuppliedDocument = { id: string; lines: (string | number)[][] };
15
+ export function suppliedCitation(
16
+ entry: z.infer<typeof citationSchema>,
17
+ documents: SuppliedDocument[],
18
+ ) {
19
+ if (entry.lineEnd < entry.lineStart) return false;
20
+ const lines = new Set(
21
+ documents
22
+ .filter((document) => document.id === entry.document)
23
+ .flatMap((document) => document.lines.map(([number]) => Number(number))),
24
+ );
25
+ for (let line = entry.lineStart; line <= entry.lineEnd; line += 1)
26
+ if (!lines.has(line)) return false;
27
+ return true;
28
+ }
29
+ export const decisionSchema = z.object({
30
+ id: z.string().min(1),
31
+ document: z.string().min(1),
32
+ text: explanation,
33
+ kind: z.enum(['decision', 'constraint', 'definition', 'lesson']),
34
+ status: z.enum(['current', 'proposed', 'historical', 'uncertain']),
35
+ conditions: z.array(explanation).max(16),
36
+ exceptions: z.array(explanation).max(16),
37
+ reason: explanation,
38
+ lineStart: z.number().int().positive(),
39
+ lineEnd: z.number().int().positive(),
40
+ });
41
+ export const relationshipSchema = z.object({
42
+ id: z.string().min(1),
43
+ from: z.string().min(1),
44
+ to: z.string().min(1),
45
+ type: z.enum(['requires', 'exception-to', 'supersedes', 'supports', 'contradicts']),
46
+ reason: explanation,
47
+ evidence: z.array(citationSchema).min(1).max(8),
48
+ });
49
+ export const extractionSchema = z.object({
50
+ decisions: z.array(decisionSchema).max(64),
51
+ relationships: z.array(relationshipSchema).max(128),
52
+ uncertainties: z.array(explanation).max(32),
53
+ });
54
+ export const checkSchema = z.object({
55
+ findings: z.array(z.object({ target: z.string().min(1), reason: explanation })).max(64),
56
+ });
57
+ const quality = z.enum(['unchecked', 'checked', 'uncertain']);
58
+ const provenance = { version: z.string(), batch: z.string(), localId: z.string(), quality };
59
+ const warningScopeSchema = citationSchema.extend({ version: z.string() });
60
+ export type WarningScope = z.infer<typeof warningScopeSchema>;
61
+ const warningSchema = z.union([
62
+ z.string(),
63
+ z.object({ message: z.string(), scope: z.array(warningScopeSchema) }),
64
+ ]);
65
+
66
+ export function warningScope(
67
+ documents: Document[],
68
+ ranges?: z.infer<typeof citationSchema>[],
69
+ ): WarningScope[] {
70
+ return (
71
+ ranges ??
72
+ documents.map((document) => ({
73
+ document: document.id,
74
+ lineStart: 1,
75
+ lineEnd: rawMarkdownLines(document.text).length,
76
+ }))
77
+ ).flatMap((range) => {
78
+ const source = documents.find((document) => document.id === range.document);
79
+ return source
80
+ ? [
81
+ {
82
+ document: range.document,
83
+ lineStart: range.lineStart,
84
+ lineEnd: range.lineEnd,
85
+ version: source.hash,
86
+ },
87
+ ]
88
+ : [];
89
+ });
90
+ }
91
+
92
+ export const graphSchema = z.object({
93
+ version: z.literal(1),
94
+ lastExtraction: z.string().optional(),
95
+ documents: z.record(z.string(), z.string()),
96
+ units: z
97
+ .record(
98
+ z.string(),
99
+ z.object({ document: z.string(), version: z.string(), workKey: z.string().optional() }),
100
+ )
101
+ .default({}),
102
+ decisions: z.array(decisionSchema.extend(provenance)),
103
+ relationships: z.array(
104
+ relationshipSchema.extend({
105
+ batch: z.string(),
106
+ localId: z.string(),
107
+ quality,
108
+ evidence: z.array(citationSchema.extend({ version: z.string().optional() })),
109
+ }),
110
+ ),
111
+ warnings: z.array(warningSchema),
112
+ });
113
+ export type Graph = z.infer<typeof graphSchema>;
114
+ export type Extraction = z.infer<typeof extractionSchema>;
115
+ export type KnowledgeCheck = z.infer<typeof checkSchema>;
116
+ export const emptyGraph = (): Graph => ({
117
+ version: 1,
118
+ documents: {},
119
+ units: {},
120
+ decisions: [],
121
+ relationships: [],
122
+ warnings: [],
123
+ });
124
+
125
+ export function validCitation(entry: z.infer<typeof citationSchema>, documents: Document[]) {
126
+ const document = documents.find((item) => item.id === entry.document);
127
+ return (
128
+ document !== undefined &&
129
+ entry.lineStart <= entry.lineEnd &&
130
+ entry.lineEnd <= rawMarkdownLines(document.text).length &&
131
+ sourceRange(document.text, entry.lineStart, entry.lineEnd).trim().length > 0
132
+ );
133
+ }
134
+
135
+ export function sourceEvidence(entry: z.infer<typeof citationSchema>, project: Project) {
136
+ const document = project.documents.find((item) => item.id === entry.document);
137
+ if (!document || !validCitation(entry, project.documents)) return null;
138
+ return {
139
+ document: entry.document,
140
+ lineStart: entry.lineStart,
141
+ lineEnd: entry.lineEnd,
142
+ version: document.hash,
143
+ text: sourceRange(document.text, entry.lineStart, entry.lineEnd),
144
+ };
145
+ }
146
+
147
+ function inRanges(
148
+ entry: z.infer<typeof citationSchema>,
149
+ ranges?: z.infer<typeof citationSchema>[],
150
+ ) {
151
+ if (!ranges) return true;
152
+ for (let line = entry.lineStart; line <= entry.lineEnd; line += 1) {
153
+ if (
154
+ !ranges.some(
155
+ (range) =>
156
+ range.document === entry.document && range.lineStart <= line && range.lineEnd >= line,
157
+ )
158
+ )
159
+ return false;
160
+ }
161
+ return true;
162
+ }
163
+
164
+ type ExtractionOptions = {
165
+ graph: Graph;
166
+ extraction: Extraction;
167
+ documents: Document[];
168
+ batch: string;
169
+ contextDocuments?: Document[];
170
+ existingIds?: string[];
171
+ targetRanges?: z.infer<typeof citationSchema>[];
172
+ contextRanges?: z.infer<typeof citationSchema>[];
173
+ };
174
+
175
+ function retainedWarnings(graph: Graph, scope: WarningScope[]) {
176
+ return graph.warnings.filter(
177
+ (warning) =>
178
+ typeof warning === 'string' ||
179
+ !warning.scope.some((old) =>
180
+ scope.some(
181
+ (current) =>
182
+ current.document === old.document &&
183
+ (current.version !== old.version ||
184
+ (current.lineStart <= old.lineEnd && current.lineEnd >= old.lineStart)),
185
+ ),
186
+ ),
187
+ );
188
+ }
189
+
190
+ export function applyExtraction(options: ExtractionOptions) {
191
+ const { graph, extraction, documents, batch } = options;
192
+ const decisions = graph.decisions.filter((entry) => {
193
+ const source = documents.find((document) => document.id === entry.document);
194
+ return (
195
+ !source ||
196
+ (source.hash === entry.version &&
197
+ validCitation(entry, [source]) &&
198
+ !options.targetRanges?.some(
199
+ (range) =>
200
+ range.document === entry.document &&
201
+ range.lineStart <= entry.lineEnd &&
202
+ range.lineEnd >= entry.lineStart,
203
+ ))
204
+ );
205
+ });
206
+ const ids = new Map(
207
+ decisions
208
+ .filter((entry) => options.existingIds?.includes(entry.id))
209
+ .map((entry) => [entry.id, entry.id]),
210
+ );
211
+ const warnings = [...extraction.uncertainties];
212
+ for (const entry of extraction.decisions) {
213
+ const source = documents.find((document) => document.id === entry.document);
214
+ if (!source || ids.has(entry.id)) {
215
+ warnings.push(`Decision ${entry.id} has an unknown, duplicate or invalid source reference.`);
216
+ continue;
217
+ }
218
+ const located = validCitation(entry, documents) && inRanges(entry, options.targetRanges);
219
+ if (!located)
220
+ warnings.push(
221
+ `Decision ${entry.id} has an unverified line range; its document remains available.`,
222
+ );
223
+ const id = digest(JSON.stringify({ version: source.hash, ...entry }));
224
+ ids.set(entry.id, id);
225
+ const previous = decisions.findIndex((decision) => decision.id === id);
226
+ if (previous >= 0) decisions.splice(previous, 1);
227
+ decisions.push({
228
+ ...entry,
229
+ id,
230
+ localId: entry.id,
231
+ version: source.hash,
232
+ batch,
233
+ quality: located ? 'unchecked' : 'uncertain',
234
+ });
235
+ }
236
+ const relationships = extractedRelationships({ options, decisions, ids, warnings });
237
+ return {
238
+ version: 1 as const,
239
+ lastExtraction: batch,
240
+ documents: Object.fromEntries(
241
+ Object.entries(graph.documents).filter(
242
+ ([id, version]) =>
243
+ !documents.some((document) => document.id === id && document.hash !== version),
244
+ ),
245
+ ),
246
+ units: Object.fromEntries(
247
+ Object.entries(graph.units).filter(
248
+ ([, unit]) =>
249
+ !documents.some(
250
+ (document) => document.id === unit.document && document.hash !== unit.version,
251
+ ),
252
+ ),
253
+ ),
254
+ decisions,
255
+ relationships,
256
+ warnings: [
257
+ ...retainedWarnings(graph, warningScope(documents, options.targetRanges)),
258
+ ...warnings.map((message) => ({
259
+ message,
260
+ scope: warningScope(documents, options.targetRanges),
261
+ })),
262
+ ],
263
+ };
264
+ }
265
+
266
+ function extractedRelationships(input: {
267
+ options: ExtractionOptions;
268
+ decisions: Graph['decisions'];
269
+ ids: Map<string, string>;
270
+ warnings: string[];
271
+ }) {
272
+ const { options, decisions, ids, warnings } = input;
273
+ const { graph, extraction, documents, batch } = options;
274
+ const available = new Set(decisions.map((entry) => entry.id));
275
+ const relationships = graph.relationships.filter(
276
+ (entry) =>
277
+ available.has(entry.from) &&
278
+ available.has(entry.to) &&
279
+ !entry.evidence.some(
280
+ (citation) =>
281
+ options.targetRanges?.some(
282
+ (range) =>
283
+ range.document === citation.document &&
284
+ range.lineStart <= citation.lineEnd &&
285
+ range.lineEnd >= citation.lineStart,
286
+ ) ||
287
+ (options.contextDocuments ?? documents).some(
288
+ (document) => document.id === citation.document && document.hash !== citation.version,
289
+ ),
290
+ ),
291
+ );
292
+ const seen = new Set<string>();
293
+ for (const entry of extraction.relationships) {
294
+ const from = ids.get(entry.from);
295
+ const to = ids.get(entry.to);
296
+ if (
297
+ !from ||
298
+ !to ||
299
+ seen.has(entry.id) ||
300
+ entry.evidence.some(
301
+ (item) =>
302
+ !validCitation(item, options.contextDocuments ?? documents) ||
303
+ !inRanges(item, options.contextRanges),
304
+ )
305
+ ) {
306
+ warnings.push(
307
+ `Relationship ${entry.id} has an unknown endpoint, duplicate ID or invalid reference.`,
308
+ );
309
+ continue;
310
+ }
311
+ seen.add(entry.id);
312
+ const evidence = entry.evidence.map((citation) => ({
313
+ ...citation,
314
+ version: (options.contextDocuments ?? documents).find(
315
+ (document) => document.id === citation.document,
316
+ )?.hash,
317
+ }));
318
+ const id = digest(JSON.stringify({ ...entry, evidence, from, to }));
319
+ const previous = relationships.findIndex((relationship) => relationship.id === id);
320
+ if (previous >= 0) relationships.splice(previous, 1);
321
+ relationships.push({
322
+ ...entry,
323
+ evidence,
324
+ id,
325
+ from,
326
+ to,
327
+ localId: entry.id,
328
+ batch,
329
+ quality: 'unchecked',
330
+ });
331
+ }
332
+ return relationships;
333
+ }
334
+
335
+ export function applyCheck(
336
+ graph: Graph,
337
+ check: KnowledgeCheck,
338
+ batch: string,
339
+ scope: WarningScope[] = [],
340
+ ): Graph {
341
+ const targets = new Set(check.findings.map((finding) => finding.target));
342
+ const known = new Set([
343
+ 'batch',
344
+ ...scope.map((entry) => entry.document),
345
+ ...graph.decisions.map((entry) => entry.id),
346
+ ...graph.relationships.map((entry) => entry.id),
347
+ ...graph.decisions
348
+ .filter((entry) => entry.batch === batch)
349
+ .flatMap((entry) => [entry.localId, entry.document]),
350
+ ...graph.relationships.filter((entry) => entry.batch === batch).map((entry) => entry.localId),
351
+ ]);
352
+ const uncertainBatch = targets.has('batch') || [...targets].some((target) => !known.has(target));
353
+ const decisions = graph.decisions.map((entry) => {
354
+ if (entry.batch !== batch && !targets.has(entry.id)) return entry;
355
+ const uncertain =
356
+ targets.has(entry.id) ||
357
+ entry.quality === 'uncertain' ||
358
+ uncertainBatch ||
359
+ targets.has(entry.localId) ||
360
+ targets.has(entry.document);
361
+ return { ...entry, quality: quality.parse(uncertain ? 'uncertain' : 'checked') };
362
+ });
363
+ const relationships = graph.relationships.map((entry) => {
364
+ if (entry.batch !== batch && !targets.has(entry.id)) return entry;
365
+ const uncertain =
366
+ targets.has(entry.id) ||
367
+ uncertainBatch ||
368
+ targets.has(entry.localId) ||
369
+ decisions.some(
370
+ (node) => (node.id === entry.from || node.id === entry.to) && node.quality === 'uncertain',
371
+ );
372
+ return { ...entry, quality: quality.parse(uncertain ? 'uncertain' : 'checked') };
373
+ });
374
+ return {
375
+ ...graph,
376
+ decisions,
377
+ relationships,
378
+ warnings: [
379
+ ...graph.warnings,
380
+ ...check.findings.map((finding) => ({
381
+ message: finding.reason,
382
+ scope: findingScope(graph, finding.target, batch, scope),
383
+ })),
384
+ ],
385
+ };
386
+ }
387
+
388
+ function findingScope(
389
+ graph: Graph,
390
+ target: string,
391
+ batch: string,
392
+ fallback: WarningScope[],
393
+ ): WarningScope[] {
394
+ const decisions = graph.decisions.filter(
395
+ (entry) =>
396
+ entry.id === target ||
397
+ (entry.batch === batch && (entry.localId === target || entry.document === target)),
398
+ );
399
+ const relationships = graph.relationships.filter(
400
+ (entry) => entry.id === target || (entry.batch === batch && entry.localId === target),
401
+ );
402
+ const scope = [
403
+ ...decisions.map(({ document, version, lineStart, lineEnd }) => ({
404
+ document,
405
+ version,
406
+ lineStart,
407
+ lineEnd,
408
+ })),
409
+ ...relationships.flatMap((entry) =>
410
+ entry.evidence.flatMap((citation) =>
411
+ citation.version ? [{ ...citation, version: citation.version }] : [],
412
+ ),
413
+ ),
414
+ ];
415
+ const documentScope = fallback.filter((entry) => entry.document === target);
416
+ if (scope.length) return scope;
417
+ return documentScope.length ? documentScope : fallback;
418
+ }