@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.
Files changed (39) hide show
  1. package/README.md +55 -163
  2. package/docs/CONTEXT.md +20 -36
  3. package/docs/README.md +6 -12
  4. package/docs/adr/0003-independent-bun-installation.md +5 -19
  5. package/docs/adr/0010-practical-knowledge-assistance.md +28 -81
  6. package/docs/adr/0011-shared-knowledge-and-selective-history.md +16 -43
  7. package/docs/guidelines/engineering.md +74 -0
  8. package/docs/procedures/self-hosted-runner.md +7 -0
  9. package/package.json +32 -11
  10. package/skills/hivex/SKILL.md +28 -92
  11. package/skills/hivex/references/markdown.md +12 -42
  12. package/src/cli/diagnostic.ts +21 -11
  13. package/src/cli.ts +46 -36
  14. package/src/documents.ts +502 -320
  15. package/src/errors.ts +8 -6
  16. package/src/implementation.ts +185 -87
  17. package/src/ingestion-units.ts +107 -64
  18. package/src/knowledge-maintenance.ts +35 -22
  19. package/src/knowledge-model.ts +386 -268
  20. package/src/knowledge-serialization.ts +239 -0
  21. package/src/knowledge-snapshot.ts +100 -77
  22. package/src/knowledge-store.ts +634 -453
  23. package/src/knowledge.ts +1001 -758
  24. package/src/markdown.ts +107 -45
  25. package/src/model/connection.ts +134 -76
  26. package/src/model/failure.ts +46 -23
  27. package/src/model/invoke.ts +346 -166
  28. package/src/model/profile.ts +201 -103
  29. package/src/model/rpc-error.ts +21 -0
  30. package/src/model/server.ts +151 -82
  31. package/src/model/thread.ts +24 -14
  32. package/src/model/transcript.ts +87 -46
  33. package/src/ordering.ts +9 -0
  34. package/src/retrieval/lexical.ts +64 -41
  35. package/src/review.ts +83 -55
  36. package/src/runtime.d.ts +4 -0
  37. package/src/snapshot-command.ts +82 -43
  38. package/src/source-relocation.ts +222 -0
  39. package/docs/engineering.md +0 -174
@@ -0,0 +1,239 @@
1
+ // Field order is part of the persisted v1 cache format. Keep model inputs stable
2
+ // when source formatting changes; unknown future fields remain part of the input.
3
+ const fieldOrder = {
4
+ citation: ['document', 'lineStart', 'lineEnd', 'version', 'historical', 'text'],
5
+ code: ['path', 'side', 'lineStart', 'lineEnd', 'version', 'text'],
6
+ context: [
7
+ 'command',
8
+ 'snapshot',
9
+ 'documents',
10
+ 'unavailableDocuments',
11
+ 'unexpandedDecisions',
12
+ 'decisions',
13
+ 'relationships',
14
+ 'pendingDocuments',
15
+ 'warnings',
16
+ ],
17
+ decision: [
18
+ 'id',
19
+ 'document',
20
+ 'text',
21
+ 'kind',
22
+ 'status',
23
+ 'conditions',
24
+ 'exceptions',
25
+ 'reason',
26
+ 'lineStart',
27
+ 'lineEnd',
28
+ 'version',
29
+ 'batch',
30
+ 'localId',
31
+ 'quality',
32
+ ],
33
+ document: ['id', 'title', 'status', 'historical', 'version', 'lineCount', 'lines'],
34
+ extraction: [
35
+ 'operation',
36
+ 'targets',
37
+ 'repairReason',
38
+ 'units',
39
+ 'documents',
40
+ 'existing',
41
+ 'previousRelationships',
42
+ 'scope',
43
+ 'extraction',
44
+ ],
45
+ file: ['path', 'before', 'after'],
46
+ finding: ['target', 'reason'],
47
+ implementation: ['baseCommit', 'diff', 'files', 'warnings', 'fingerprint'],
48
+ packet: [
49
+ 'operation',
50
+ 'implementation',
51
+ 'task',
52
+ 'context',
53
+ 'documents',
54
+ 'omittedUnits',
55
+ 'warnings',
56
+ ],
57
+ queriedDecision: [
58
+ 'historical',
59
+ 'id',
60
+ 'document',
61
+ 'version',
62
+ 'text',
63
+ 'kind',
64
+ 'status',
65
+ 'quality',
66
+ 'conditions',
67
+ 'exceptions',
68
+ 'reason',
69
+ 'evidence',
70
+ ],
71
+ relationship: ['id', 'from', 'to', 'type', 'reason', 'evidence', 'batch', 'localId', 'quality'],
72
+ reviewFinding: ['assessment', 'explanation', 'documents', 'code'],
73
+ unit: ['id', 'document', 'hash', 'lineStart', 'lineEnd', 'text'],
74
+ version: ['version', 'lines'],
75
+ warning: ['path', 'message'],
76
+ };
77
+
78
+ const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
79
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
80
+ };
81
+ const hasFields = function hasFields(value: Record<string, unknown>, fields: string[]) {
82
+ return fields.every((field) => Object.hasOwn(value, field));
83
+ };
84
+ const orderRecord = function orderRecord(value: Record<string, unknown>, order: string[]) {
85
+ const existing = Object.keys(value);
86
+ const names = new Set(Iterator.concat(order, existing));
87
+ return Object.fromEntries(
88
+ names
89
+ .values()
90
+ .filter((name) => existing.includes(name))
91
+ .map<[string, unknown]>((name) => [name, value[name]])
92
+ );
93
+ };
94
+
95
+ const hasOrderPrefix = function hasOrderPrefix(value: Record<string, unknown>, fields: string[]) {
96
+ const keys = Object.keys(value);
97
+ return fields.every((field, index) => keys[index] === field);
98
+ };
99
+
100
+ // New graph records must retain the original live provenance order between
101
+ // rounds. Decoding a stored graph has its own schema order.
102
+ export const withLiveProvenance = function withLiveProvenance<T extends object>(
103
+ value: T,
104
+ kind: 'decision' | 'relationship'
105
+ ): T {
106
+ const record = { ...value };
107
+ const fields =
108
+ kind === 'decision'
109
+ ? [...fieldOrder.decision.slice(0, 10), 'localId', 'version', 'batch', 'quality']
110
+ : [...fieldOrder.relationship.slice(0, 6), 'localId', 'batch', 'quality'];
111
+ for (const field of fields) {
112
+ const descriptor = Object.getOwnPropertyDescriptor(record, field);
113
+ if (descriptor !== undefined) {
114
+ Reflect.deleteProperty(record, field);
115
+ Object.defineProperty(record, field, descriptor);
116
+ }
117
+ }
118
+ return record;
119
+ };
120
+
121
+ const sourceOrder = function sourceOrder(value: Record<string, unknown>) {
122
+ if (hasFields(value, ['kind', 'id'])) {
123
+ if (Object.hasOwn(value, 'historical')) {
124
+ return fieldOrder.queriedDecision;
125
+ }
126
+ // Retained packets may contain live graph records, whose provenance fields
127
+ // were appended in a different order from records decoded by the store.
128
+ return hasOrderPrefix(value, fieldOrder.decision.slice(0, 10)) ? null : fieldOrder.decision;
129
+ }
130
+ if (hasFields(value, ['from', 'to', 'type'])) {
131
+ return hasOrderPrefix(value, fieldOrder.relationship.slice(0, 6))
132
+ ? null
133
+ : fieldOrder.relationship;
134
+ }
135
+ if (hasFields(value, ['id', 'title'])) {
136
+ return fieldOrder.document;
137
+ }
138
+ if (hasFields(value, ['document', 'lineStart', 'lineEnd'])) {
139
+ return Object.hasOwn(value, 'id') ? fieldOrder.unit : fieldOrder.citation;
140
+ }
141
+ if (hasFields(value, ['path', 'side'])) {
142
+ return fieldOrder.code;
143
+ }
144
+ if (hasFields(value, ['version', 'lines'])) {
145
+ return fieldOrder.version;
146
+ }
147
+ if (hasFields(value, ['path', 'message'])) {
148
+ return fieldOrder.warning;
149
+ }
150
+ // No source shape matched. Null above preserves a recognized record's live order.
151
+ return [];
152
+ };
153
+
154
+ const packetOrder = function packetOrder(key: string, value: Record<string, unknown>) {
155
+ if (key === '' && typeof value.operation === 'string') {
156
+ const isExtraction = value.operation === 'extract' || value.operation === 'check';
157
+ return isExtraction ? fieldOrder.extraction : fieldOrder.packet;
158
+ }
159
+ if (key === 'context' && hasFields(value, ['command', 'snapshot'])) {
160
+ return fieldOrder.context;
161
+ }
162
+ if (hasFields(value, ['baseCommit', 'files'])) {
163
+ return fieldOrder.implementation;
164
+ }
165
+ if (hasFields(value, ['path', 'before', 'after'])) {
166
+ return fieldOrder.file;
167
+ }
168
+ const order = sourceOrder(value);
169
+ if (order === null || order.length > 0) {
170
+ return order;
171
+ }
172
+ return hasFields(value, ['target', 'reason']) ? fieldOrder.finding : null;
173
+ };
174
+
175
+ export const stringifyKnowledge = function stringifyKnowledge(packet: unknown) {
176
+ return JSON.stringify(packet, (key: string, value: unknown) => {
177
+ if (!isRecord(value)) {
178
+ return value;
179
+ }
180
+ const order = packetOrder(key, value);
181
+ return order === null ? value : orderRecord(value, order);
182
+ });
183
+ };
184
+
185
+ const schemaOrder = function schemaOrder(properties: Record<string, unknown>) {
186
+ if (hasFields(properties, ['kind', 'id'])) {
187
+ return fieldOrder.decision;
188
+ }
189
+ if (hasFields(properties, ['from', 'to', 'type'])) {
190
+ return fieldOrder.relationship;
191
+ }
192
+ if (hasFields(properties, ['document', 'lineStart', 'lineEnd'])) {
193
+ return fieldOrder.citation;
194
+ }
195
+ if (hasFields(properties, ['path', 'side'])) {
196
+ return fieldOrder.code;
197
+ }
198
+ if (hasFields(properties, ['assessment', 'explanation'])) {
199
+ return fieldOrder.reviewFinding;
200
+ }
201
+ if (hasFields(properties, ['target', 'reason'])) {
202
+ return fieldOrder.finding;
203
+ }
204
+ return null;
205
+ };
206
+
207
+ const schemaNode = function schemaNode(value: unknown): unknown {
208
+ if (Array.isArray(value)) {
209
+ return value.map(schemaNode);
210
+ }
211
+ if (!isRecord(value)) {
212
+ return value;
213
+ }
214
+ const result = Object.fromEntries(
215
+ Object.entries(value).map(([key, node]) => [key, schemaNode(node)])
216
+ );
217
+ if (!isRecord(result.properties)) {
218
+ return result;
219
+ }
220
+ const order = schemaOrder(result.properties);
221
+ if (order === null) {
222
+ return result;
223
+ }
224
+ result.properties = orderRecord(result.properties, order);
225
+ if (Array.isArray(result.required)) {
226
+ const { required } = result;
227
+ const names = new Set(Iterator.concat(order, required));
228
+ result.required = [...names].filter((name) => required.includes(name));
229
+ }
230
+ return result;
231
+ };
232
+
233
+ export const schemaForKnowledge = function schemaForKnowledge(schema: Record<string, unknown>) {
234
+ const result = schemaNode(schema);
235
+ if (!isRecord(result)) {
236
+ throw new Error('Expected a JSON schema object');
237
+ }
238
+ return result;
239
+ };
@@ -1,140 +1,163 @@
1
1
  import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
2
  import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { compareSerializedStrings } from './ordering.ts';
4
5
  import { HivexError } from './errors.ts';
5
6
  import { isMarkdownPath } from './markdown.ts';
6
- import { emptyGraph, graphSchema, type Graph } from './knowledge-model.ts';
7
+ import { emptyGraph, graphSchema } from './knowledge-model.ts';
8
+ import type { Graph } from './knowledge-model.ts';
7
9
 
8
- const MAX_BYTES = 64 * 1024 * 1024;
10
+ const maxBytes = 64 * 1024 * 1024;
9
11
 
10
- function portableDocument(id: string) {
11
- return (
12
- isMarkdownPath(id) &&
13
- !id.startsWith('/') &&
14
- !id.includes('\\') &&
15
- !id.includes('\0') &&
16
- !id.split('/').some((part) => ['..', '.', '.git', '.hivex', 'node_modules', ''].includes(part))
17
- );
18
- }
12
+ const isPortableDocument = function isPortableDocument(id: string) {
13
+ const isProjectRelative = !id.startsWith('/') && !id.includes('\\') && !id.includes('\0');
14
+ const hasSafeParts = id
15
+ .split('/')
16
+ .every((part) => !['..', '.', '.git', '.hivex', 'node_modules', ''].includes(part));
17
+ return isMarkdownPath(id) && isProjectRelative && hasSafeParts;
18
+ };
19
19
 
20
- function validateGraph(graph: Graph) {
20
+ const validateGraph = function validateGraph(graph: Graph) {
21
21
  const decisions = new Set(graph.decisions.map((entry) => entry.id));
22
22
  const relationships = new Set(graph.relationships.map((entry) => entry.id));
23
- const invalid =
23
+ const hasInvalidDecision = graph.decisions.some((entry) => {
24
+ if (entry.quality !== 'checked') {
25
+ return false;
26
+ }
27
+ return entry.lineStart > entry.lineEnd;
28
+ });
29
+ const hasInvalidRelationship = graph.relationships.some((edge) => {
30
+ if (!decisions.has(edge.from) || !decisions.has(edge.to)) {
31
+ return true;
32
+ }
33
+ if (edge.evidence.length === 0) {
34
+ return true;
35
+ }
36
+ return edge.evidence.some((entry) => entry.lineStart > entry.lineEnd);
37
+ });
38
+ const isInvalid =
24
39
  decisions.size !== graph.decisions.length ||
25
40
  relationships.size !== graph.relationships.length ||
26
- graph.decisions.some(
27
- (entry) => entry.quality === 'checked' && entry.lineStart > entry.lineEnd,
28
- ) ||
29
- graph.relationships.some(
30
- (edge) =>
31
- !decisions.has(edge.from) ||
32
- !decisions.has(edge.to) ||
33
- !edge.evidence.length ||
34
- edge.evidence.some((entry) => entry.lineStart > entry.lineEnd),
35
- );
41
+ hasInvalidDecision ||
42
+ hasInvalidRelationship;
36
43
  const references = [
37
44
  ...Object.keys(graph.documents),
38
45
  ...Object.values(graph.units).map((unit) => unit.document),
39
46
  ...graph.decisions.map((entry) => entry.document),
40
47
  ...graph.relationships.flatMap((edge) => edge.evidence.map((entry) => entry.document)),
41
- ...graph.warnings.flatMap((warning) =>
42
- typeof warning === 'string' ? [] : warning.scope.map((entry) => entry.document),
43
- ),
48
+ ...graph.warnings.flatMap((warning) => {
49
+ if (typeof warning === 'string') {
50
+ return [];
51
+ }
52
+ return warning.scope.map((entry) => entry.document);
53
+ }),
44
54
  ];
45
- if (invalid || references.some((id) => !portableDocument(id)))
55
+ if (isInvalid || references.some((id) => !isPortableDocument(id))) {
46
56
  throw new HivexError({
47
57
  code: 'INVALID_SNAPSHOT',
48
58
  message:
49
59
  'Knowledge snapshot has invalid identities, relationships or project-relative sources.',
50
60
  });
61
+ }
51
62
  return graph;
52
- }
63
+ };
53
64
 
54
- function snapshotPath(root: string) {
55
- const directory = join(root, '.hivex');
56
- const path = join(directory, 'graph.json');
57
- for (const candidate of [directory, path]) {
65
+ const snapshotPath = function snapshotPath(root: string) {
66
+ const directory = path.join(root, '.hivex');
67
+ const snapshotFile = path.join(directory, 'graph.json');
68
+ for (const candidate of [directory, snapshotFile]) {
58
69
  const stat = lstatSync(candidate, { throwIfNoEntry: false });
59
70
  if (
60
- stat?.isSymbolicLink() ||
71
+ stat?.isSymbolicLink() === true ||
61
72
  (stat && !(candidate === directory ? stat.isDirectory() : stat.isFile()))
62
- )
73
+ ) {
63
74
  throw new HivexError({
64
75
  code: 'INVALID_SNAPSHOT',
65
76
  message: 'Knowledge snapshot must use regular project-local files.',
66
77
  });
78
+ }
67
79
  }
68
- return path;
69
- }
80
+ return snapshotFile;
81
+ };
70
82
 
71
- export function readKnowledgeSnapshot(root: string): Graph | null {
72
- const path = snapshotPath(root);
73
- const stat = lstatSync(path, { throwIfNoEntry: false });
74
- if (!stat) return null;
75
- if (stat.size > MAX_BYTES)
83
+ const parseSnapshot = function parseSnapshot(bytes: Uint8Array) {
84
+ if (bytes.length > maxBytes) {
85
+ throw new Error('Snapshot exceeds size limit');
86
+ }
87
+ const decoder = new TextDecoder('utf-8', { fatal: true });
88
+ const data: unknown = JSON.parse(decoder.decode(bytes));
89
+ return validateGraph(graphSchema.strict().parse(data));
90
+ };
91
+
92
+ export const readKnowledgeSnapshot = function readKnowledgeSnapshot(root: string): Graph | null {
93
+ const snapshotFile = snapshotPath(root);
94
+ const stat = lstatSync(snapshotFile, { throwIfNoEntry: false });
95
+ if (stat === undefined) {
96
+ return null;
97
+ }
98
+ if (stat.size > maxBytes) {
76
99
  throw new HivexError({
77
100
  code: 'INVALID_SNAPSHOT',
78
101
  message: 'Knowledge snapshot exceeds 64 MiB.',
79
102
  });
103
+ }
80
104
  try {
81
- const bytes = readFileSync(path);
82
- if (bytes.length > MAX_BYTES) throw new Error('Snapshot exceeds size limit');
83
- const data: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
84
- return validateGraph(graphSchema.strict().parse(data));
105
+ return parseSnapshot(readFileSync(snapshotFile));
85
106
  } catch {
86
107
  throw new HivexError({
87
108
  code: 'INVALID_SNAPSHOT',
88
109
  message: 'Knowledge snapshot is not a supported graph JSON document.',
89
110
  });
90
111
  }
91
- }
112
+ };
92
113
 
93
- function ordered(value: unknown): unknown {
94
- if (Array.isArray(value)) return value.map(ordered);
95
- if (value && typeof value === 'object')
114
+ const ordered = function ordered(value: unknown): unknown {
115
+ if (Array.isArray(value)) {
116
+ return value.map(ordered);
117
+ }
118
+ if (value !== null && typeof value === 'object') {
96
119
  return Object.fromEntries(
97
120
  Object.entries(value)
98
- .sort(([a], [b]) => (a < b ? -1 : Number(a > b)))
99
- .map(([key, entry]) => [key, ordered(entry)]),
121
+ .toSorted(([a], [b]) => compareSerializedStrings(a, b))
122
+ .map(([key, entry]) => [key, ordered(entry)])
100
123
  );
124
+ }
101
125
  return value;
102
- }
126
+ };
103
127
 
104
- function byId(a: { id: string }, b: { id: string }) {
105
- if (a.id < b.id) return -1;
106
- return Number(a.id > b.id);
107
- }
128
+ const byId = function byId(a: { id: string }, b: { id: string }) {
129
+ return compareSerializedStrings(a.id, b.id);
130
+ };
108
131
 
109
- export function writeKnowledgeSnapshot(root: string, value: Graph) {
110
- const path = snapshotPath(root);
132
+ export const writeKnowledgeSnapshot = function writeKnowledgeSnapshot(root: string, value: Graph) {
133
+ const snapshotFile = snapshotPath(root);
111
134
  const graph = validateGraph(graphSchema.parse(value));
112
- const text =
113
- JSON.stringify(
114
- ordered({
115
- ...graph,
116
- decisions: graph.decisions.toSorted(byId),
117
- relationships: graph.relationships.toSorted(byId),
118
- }),
119
- null,
120
- 2,
121
- ) + '\n';
122
- if (Buffer.byteLength(text) > MAX_BYTES)
135
+ const text = `${JSON.stringify(
136
+ ordered({
137
+ ...graph,
138
+ decisions: graph.decisions.toSorted(byId),
139
+ relationships: graph.relationships.toSorted(byId),
140
+ }),
141
+ null,
142
+ 2
143
+ )}\n`;
144
+ if (Buffer.byteLength(text) > maxBytes) {
123
145
  throw new HivexError({
124
146
  code: 'INVALID_SNAPSHOT',
125
147
  message: 'Knowledge snapshot exceeds 64 MiB.',
126
148
  });
127
- mkdirSync(join(root, '.hivex'), { recursive: true, mode: 0o700 });
128
- const temporary = join(root, '.hivex', `graph-${randomUUID()}.tmp`);
149
+ }
150
+ mkdirSync(path.join(root, '.hivex'), { mode: 0o700, recursive: true });
151
+ const temporary = path.join(root, '.hivex', `graph-${randomUUID()}.tmp`);
129
152
  try {
130
153
  writeFileSync(temporary, text, { flag: 'wx', mode: 0o600 });
131
- renameSync(temporary, path);
154
+ renameSync(temporary, snapshotFile);
132
155
  } finally {
133
156
  rmSync(temporary, { force: true });
134
157
  }
135
- return path;
136
- }
158
+ return snapshotFile;
159
+ };
137
160
 
138
- export function sharedKnowledge(root: string) {
161
+ export const sharedKnowledge = function sharedKnowledge(root: string) {
139
162
  return readKnowledgeSnapshot(root) ?? emptyGraph();
140
- }
163
+ };