knodin 0.12.2 → 0.13.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 +16 -1
- package/dist/bin/cli.js +245 -18
- package/dist/src/agent-events.js +25 -7
- package/dist/src/authenticated-cursor.js +81 -0
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/class-consumer-contract.js +18 -0
- package/dist/src/class-consumer-cursor.js +91 -0
- package/dist/src/class-consumer-delivery.js +22 -0
- package/dist/src/class-consumer-page.js +148 -0
- package/dist/src/cli-model.js +21 -3
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/apex-class-uses.js +430 -0
- package/dist/src/engine/apex-entry-points.js +98 -0
- package/dist/src/engine/apex-receiver.js +301 -0
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/embedding-reuse.js +57 -0
- package/dist/src/engine/embeddings.js +22 -0
- package/dist/src/engine/index-coverage.js +215 -0
- package/dist/src/engine/index.js +3854 -448
- package/dist/src/engine/salesforce-components.js +460 -0
- package/dist/src/engine/seal.js +3 -0
- package/dist/src/engine/sqlite.js +44 -0
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/evidence-bundle.js +283 -0
- package/dist/src/evidence-graph.js +163 -0
- package/dist/src/failure-diagnosis.js +80 -5
- package/dist/src/file-dependency.js +35 -0
- package/dist/src/graph-query-health.js +47 -1
- package/dist/src/implementation-search.js +69 -0
- package/dist/src/index-coverage-read.js +33 -0
- package/dist/src/investigation.js +195 -0
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mcp-reliability.js +4 -0
- package/dist/src/mcp-worker-supervisor.js +122 -6
- package/dist/src/mirror.js +2 -0
- package/dist/src/progressive-evidence.js +4 -4
- package/dist/src/response-budget.js +129 -3
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/publisher.js +41 -1
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/tools/knodin-tools.js +224 -41
- package/dist/src/worktree-seed.js +7 -1
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +98 -2
- package/docs/MCP.md +75 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
- package/docs/SALESFORCE-BINDINGS.md +121 -0
- package/docs/SALESFORCE-DEAD-CODE.md +45 -0
- package/docs/SCOPED-INDEXING.md +76 -0
- package/docs/apex-receiver-resolution.md +41 -0
- package/docs/releases/0.13.0.md +62 -0
- package/docs/releases/0.13.1.md +216 -0
- package/docs/structural-only-indexing.md +20 -0
- package/package.json +12 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -32,6 +33,28 @@ export const MIRROR_STATE_DIR = "state";
|
|
|
32
33
|
/** In-tree state directory name for a normal checkout. */
|
|
33
34
|
export const IN_TREE_STATE_DIR = ".knodin";
|
|
34
35
|
const REGISTRY_VERSION = 1;
|
|
36
|
+
const MIRROR_IDENTITY = /^repo_[0-9a-f]{20}$/;
|
|
37
|
+
function assertMirrorIdentity(identity) {
|
|
38
|
+
if (!MIRROR_IDENTITY.test(identity))
|
|
39
|
+
throw new Error("mirror registry: invalid repository identity");
|
|
40
|
+
}
|
|
41
|
+
function containedMirrorDirectory(parent, name) {
|
|
42
|
+
const target = path.join(parent, name);
|
|
43
|
+
for (const directory of [parent, target]) {
|
|
44
|
+
try {
|
|
45
|
+
const stat = fs.lstatSync(directory);
|
|
46
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
47
|
+
throw new Error("mirror registry: unsafe state directory");
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (fs.existsSync(target) && path.dirname(fs.realpathSync(target)) !== fs.realpathSync(parent))
|
|
55
|
+
throw new Error("mirror registry: escaped state directory");
|
|
56
|
+
return target;
|
|
57
|
+
}
|
|
35
58
|
const EMPTY_REGISTRY = { version: REGISTRY_VERSION, mirrors: [] };
|
|
36
59
|
/**
|
|
37
60
|
* Root for all mirror storage. Defaults to `~/.knodin/mirrors` rather than an
|
|
@@ -49,19 +72,20 @@ export function mirrorRegistryPath() {
|
|
|
49
72
|
}
|
|
50
73
|
/** Root for in-progress clones, renamed into place only once complete. */
|
|
51
74
|
export function mirrorStagingRoot() {
|
|
52
|
-
return
|
|
75
|
+
return containedMirrorDirectory(mirrorRoot(), ".staging");
|
|
53
76
|
}
|
|
54
77
|
/** Entry root for one mirror: the parent of both `source/` and `state/`. */
|
|
55
78
|
export function mirrorEntryPath(identity) {
|
|
56
|
-
|
|
79
|
+
assertMirrorIdentity(identity);
|
|
80
|
+
return containedMirrorDirectory(mirrorRoot(), identity);
|
|
57
81
|
}
|
|
58
82
|
/** Absolute path to a mirror's shallow clone. */
|
|
59
83
|
export function mirrorSourcePath(identity) {
|
|
60
|
-
return
|
|
84
|
+
return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
|
|
61
85
|
}
|
|
62
86
|
/** Absolute path to a mirror's graph state directory. */
|
|
63
87
|
export function mirrorStatePath(identity) {
|
|
64
|
-
return
|
|
88
|
+
return containedMirrorDirectory(mirrorEntryPath(identity), MIRROR_STATE_DIR);
|
|
65
89
|
}
|
|
66
90
|
let cache = null;
|
|
67
91
|
function isMirrorRecord(value) {
|
|
@@ -69,10 +93,13 @@ function isMirrorRecord(value) {
|
|
|
69
93
|
return false;
|
|
70
94
|
const record = value;
|
|
71
95
|
return (typeof record.identity === "string" &&
|
|
96
|
+
MIRROR_IDENTITY.test(record.identity) &&
|
|
72
97
|
typeof record.url === "string" &&
|
|
73
98
|
typeof record.path === "string" &&
|
|
74
99
|
typeof record.fetchedAt === "string" &&
|
|
75
|
-
typeof record.sha === "string"
|
|
100
|
+
typeof record.sha === "string" &&
|
|
101
|
+
record.readOnly === true &&
|
|
102
|
+
path.isAbsolute(record.path));
|
|
76
103
|
}
|
|
77
104
|
function indexByPath(registry) {
|
|
78
105
|
const byPath = new Map();
|
|
@@ -80,57 +107,172 @@ function indexByPath(registry) {
|
|
|
80
107
|
byPath.set(path.resolve(record.path), record);
|
|
81
108
|
return byPath;
|
|
82
109
|
}
|
|
110
|
+
function assertMirrorRecordPath(record) {
|
|
111
|
+
const expected = mirrorSourcePath(record.identity);
|
|
112
|
+
try {
|
|
113
|
+
if (fs.realpathSync(record.path) !== fs.realpathSync(expected))
|
|
114
|
+
throw new Error("mirror registry: clone path does not match repository identity");
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code !== "ENOENT")
|
|
118
|
+
throw error;
|
|
119
|
+
// A missing clone remains registered for actionable recovery, but only at
|
|
120
|
+
// its deterministic managed location. A missing alias cannot be verified.
|
|
121
|
+
if (path.resolve(record.path) !== path.resolve(expected))
|
|
122
|
+
throw new Error("mirror registry: missing clone path does not match repository identity");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
83
125
|
/**
|
|
84
126
|
* Reads the mirror registry, memoized against the file's mtime and size so a
|
|
85
127
|
* mirror added by a concurrent `knodin remote add` is picked up without paying a
|
|
86
128
|
* JSON parse on every path resolution.
|
|
87
129
|
*
|
|
88
|
-
* The memo
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* writer drops the memo explicitly, and the cost of being wrong is one stale
|
|
93
|
-
* read, not corruption. Recorded so it reads as a decision, not an oversight.
|
|
130
|
+
* The metadata memo includes inode/device, ctime, mtime and size. Registry reads
|
|
131
|
+
* use a bounded no-follow descriptor and verify file identity before caching.
|
|
132
|
+
* This is substitution detection, not atomic isolation from hostile filesystem
|
|
133
|
+
* writers; every derived mirror-state path also checks directory containment.
|
|
94
134
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* in-tree state — the pre-existing behaviour — rather than failing a query
|
|
98
|
-
* outright.
|
|
135
|
+
* Only a genuinely absent registry reads as empty. Corrupt, unsafe or unreadable
|
|
136
|
+
* registries fail closed: fallback could redirect a mirror into its source tree.
|
|
99
137
|
*/
|
|
100
138
|
export function readMirrorRegistry() {
|
|
101
139
|
const file = mirrorRegistryPath();
|
|
102
140
|
let stat;
|
|
103
141
|
try {
|
|
104
|
-
stat = fs.
|
|
142
|
+
stat = fs.lstatSync(file);
|
|
105
143
|
}
|
|
106
|
-
catch {
|
|
144
|
+
catch (error) {
|
|
145
|
+
cache = null;
|
|
146
|
+
if (error.code === "ENOENT")
|
|
147
|
+
return EMPTY_REGISTRY;
|
|
148
|
+
throw new Error("mirror registry: unavailable registry", { cause: error });
|
|
149
|
+
}
|
|
150
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) {
|
|
107
151
|
cache = null;
|
|
108
|
-
|
|
152
|
+
throw new Error("mirror registry: unsafe or oversized registry");
|
|
109
153
|
}
|
|
110
|
-
if (cache?.
|
|
154
|
+
if (cache?.device === stat.dev &&
|
|
155
|
+
cache.inode === stat.ino &&
|
|
156
|
+
cache.ctimeMs === stat.ctimeMs &&
|
|
157
|
+
cache.mtimeMs === stat.mtimeMs &&
|
|
158
|
+
cache.size === stat.size)
|
|
111
159
|
return cache.registry;
|
|
112
160
|
try {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
const opened = fs.fstatSync(descriptor);
|
|
165
|
+
if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size)
|
|
166
|
+
throw new Error("registry changed during open");
|
|
167
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
168
|
+
const count = fs.readSync(descriptor, bytes, 0, bytes.length, 0);
|
|
169
|
+
const final = fs.fstatSync(descriptor);
|
|
170
|
+
const current = fs.lstatSync(file);
|
|
171
|
+
if (count !== stat.size ||
|
|
172
|
+
final.size !== stat.size ||
|
|
173
|
+
final.ctimeMs !== stat.ctimeMs ||
|
|
174
|
+
current.dev !== stat.dev ||
|
|
175
|
+
current.ino !== stat.ino ||
|
|
176
|
+
current.ctimeMs !== stat.ctimeMs)
|
|
177
|
+
throw new Error("registry changed during read");
|
|
178
|
+
parsed = JSON.parse(bytes.subarray(0, count).toString("utf8"));
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
fs.closeSync(descriptor);
|
|
182
|
+
}
|
|
183
|
+
if (!parsed ||
|
|
184
|
+
typeof parsed !== "object" ||
|
|
185
|
+
parsed.version !== REGISTRY_VERSION ||
|
|
186
|
+
!Array.isArray(parsed.mirrors) ||
|
|
187
|
+
!parsed.mirrors.every(isMirrorRecord))
|
|
188
|
+
throw new Error("invalid mirror registry records");
|
|
189
|
+
const mirrors = parsed.mirrors;
|
|
190
|
+
if (new Set(mirrors.map((record) => record.identity)).size !== mirrors.length)
|
|
191
|
+
throw new Error("duplicate mirror identity");
|
|
192
|
+
for (const record of mirrors) {
|
|
193
|
+
mirrorStatePath(record.identity);
|
|
194
|
+
assertMirrorRecordPath(record);
|
|
195
|
+
}
|
|
196
|
+
const canonicalPaths = mirrors.map((record) => fs.existsSync(record.path) ? fs.realpathSync(record.path) : path.resolve(record.path));
|
|
197
|
+
if (new Set(canonicalPaths).size !== canonicalPaths.length)
|
|
198
|
+
throw new Error("duplicate mirror clone path");
|
|
119
199
|
const registry = { version: REGISTRY_VERSION, mirrors };
|
|
120
|
-
cache = {
|
|
200
|
+
cache = {
|
|
201
|
+
device: stat.dev,
|
|
202
|
+
inode: stat.ino,
|
|
203
|
+
ctimeMs: stat.ctimeMs,
|
|
204
|
+
mtimeMs: stat.mtimeMs,
|
|
205
|
+
size: stat.size,
|
|
206
|
+
registry,
|
|
207
|
+
byPath: indexByPath(registry),
|
|
208
|
+
};
|
|
121
209
|
return registry;
|
|
122
210
|
}
|
|
123
|
-
catch {
|
|
211
|
+
catch (error) {
|
|
124
212
|
cache = null;
|
|
125
|
-
|
|
213
|
+
throw new Error("mirror registry: invalid or unsafe registry; restore it before graph access", {
|
|
214
|
+
cause: error,
|
|
215
|
+
});
|
|
126
216
|
}
|
|
127
217
|
}
|
|
128
218
|
/** Persists the registry and drops the memo so the next read reloads it. */
|
|
129
219
|
export function writeMirrorRegistry(registry) {
|
|
220
|
+
if (!registry.mirrors.every(isMirrorRecord))
|
|
221
|
+
throw new Error("mirror registry: invalid records");
|
|
222
|
+
for (const record of registry.mirrors) {
|
|
223
|
+
mirrorStatePath(record.identity);
|
|
224
|
+
assertMirrorRecordPath(record);
|
|
225
|
+
}
|
|
130
226
|
const file = mirrorRegistryPath();
|
|
131
|
-
|
|
227
|
+
const parent = path.dirname(file);
|
|
228
|
+
containedMirrorDirectory(parent, ".staging");
|
|
229
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
230
|
+
const parentIdentity = fs.lstatSync(parent);
|
|
231
|
+
const canonicalParent = fs.realpathSync(parent);
|
|
232
|
+
const verifyDestination = () => {
|
|
233
|
+
const current = fs.lstatSync(parent);
|
|
234
|
+
if (!current.isDirectory() ||
|
|
235
|
+
current.isSymbolicLink() ||
|
|
236
|
+
current.dev !== parentIdentity.dev ||
|
|
237
|
+
current.ino !== parentIdentity.ino ||
|
|
238
|
+
fs.realpathSync(parent) !== canonicalParent)
|
|
239
|
+
throw new Error("mirror registry: parent identity changed");
|
|
240
|
+
try {
|
|
241
|
+
const target = fs.lstatSync(file);
|
|
242
|
+
if (!target.isFile() || target.isSymbolicLink())
|
|
243
|
+
throw new Error("mirror registry: unsafe registry destination");
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error.code !== "ENOENT")
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
verifyDestination();
|
|
132
251
|
const document = { version: REGISTRY_VERSION, mirrors: registry.mirrors };
|
|
133
|
-
|
|
252
|
+
const bytes = Buffer.from(`${JSON.stringify(document, null, 2)}\n`);
|
|
253
|
+
if (bytes.length > 1_048_576)
|
|
254
|
+
throw new Error("mirror registry: oversized registry");
|
|
255
|
+
const temporary = path.join(parent, `.registry-${process.pid}-${crypto.randomUUID()}.tmp`);
|
|
256
|
+
try {
|
|
257
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
258
|
+
try {
|
|
259
|
+
fs.writeFileSync(descriptor, bytes);
|
|
260
|
+
fs.fsyncSync(descriptor);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
fs.closeSync(descriptor);
|
|
264
|
+
}
|
|
265
|
+
verifyDestination();
|
|
266
|
+
fs.renameSync(temporary, file);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
// Only remove the exclusive temporary name while its parent is unchanged.
|
|
270
|
+
const current = fs.lstatSync(parent);
|
|
271
|
+
if (current.dev === parentIdentity.dev &&
|
|
272
|
+
current.ino === parentIdentity.ino &&
|
|
273
|
+
!current.isSymbolicLink())
|
|
274
|
+
fs.rmSync(temporary, { force: true });
|
|
275
|
+
}
|
|
134
276
|
cache = null;
|
|
135
277
|
}
|
|
136
278
|
/** Clears the in-process registry memo. Test seam. */
|
|
@@ -144,7 +286,26 @@ export function __resetMirrorRegistryCache() {
|
|
|
144
286
|
*/
|
|
145
287
|
export function lookupMirror(repoPath) {
|
|
146
288
|
readMirrorRegistry();
|
|
147
|
-
|
|
289
|
+
const exact = cache?.byPath.get(path.resolve(repoPath));
|
|
290
|
+
if (exact)
|
|
291
|
+
return exact;
|
|
292
|
+
let canonical;
|
|
293
|
+
try {
|
|
294
|
+
canonical = fs.realpathSync(repoPath);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
for (const record of cache?.byPath.values() ?? []) {
|
|
300
|
+
try {
|
|
301
|
+
if (fs.realpathSync(record.path) === canonical)
|
|
302
|
+
return record;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* Missing clones do not match. */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
148
309
|
}
|
|
149
310
|
/** True when `repoPath` is a registered read-only mirror clone. */
|
|
150
311
|
export function isMirror(repoPath) {
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { compareBytes } from "./compare.js";
|
|
4
|
+
import { indexCoverageCore } from "./index-coverage-read.js";
|
|
5
|
+
import { evidenceHandleSecret, normalizeEvidenceFile } from "./progressive-evidence.js";
|
|
6
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
7
|
+
const MAX_FILES = 32;
|
|
8
|
+
const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
/** This text protocol must not certify replacement characters for malformed disk bytes. */
|
|
10
|
+
export function readEvidenceUtf8(absolute) {
|
|
11
|
+
if (fs.statSync(absolute).size > MAX_SOURCE_BYTES)
|
|
12
|
+
throw new RangeError("evidence bundle source exceeds 8 MiB; narrow the selection");
|
|
13
|
+
const raw = fs.readFileSync(absolute);
|
|
14
|
+
if (raw.length > MAX_SOURCE_BYTES)
|
|
15
|
+
throw new RangeError("evidence bundle source exceeds 8 MiB; narrow the selection");
|
|
16
|
+
const source = raw.toString("utf8");
|
|
17
|
+
if (!Buffer.from(source, "utf8").equals(raw))
|
|
18
|
+
throw new Error("evidence bundle requires valid UTF-8 source");
|
|
19
|
+
return { source, bytes: raw.length };
|
|
20
|
+
}
|
|
21
|
+
function sign(value, secret) {
|
|
22
|
+
const body = Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
23
|
+
return `peb1.${body}.${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
function verify(value, secret) {
|
|
26
|
+
if (typeof value !== "string" || value.length > 4096)
|
|
27
|
+
throw new Error("invalid evidence bundle handle");
|
|
28
|
+
const [prefix, body, signature, extra] = value.split(".");
|
|
29
|
+
if (prefix !== "peb1" || !body || !signature || extra !== undefined)
|
|
30
|
+
throw new Error("invalid evidence bundle handle");
|
|
31
|
+
const expected = Buffer.from(createHmac("sha256", secret).update(body).digest("hex"));
|
|
32
|
+
const actual = Buffer.from(signature);
|
|
33
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
34
|
+
throw new Error("invalid evidence bundle handle");
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error("invalid evidence bundle handle");
|
|
41
|
+
}
|
|
42
|
+
if (!parsed || typeof parsed !== "object")
|
|
43
|
+
throw new Error("invalid evidence bundle handle");
|
|
44
|
+
if (parsed.type === "file-complete" &&
|
|
45
|
+
typeof parsed.file === "string" &&
|
|
46
|
+
/^[a-f0-9]{64}$/.test(parsed.hash) &&
|
|
47
|
+
Number.isSafeInteger(parsed.bytes) &&
|
|
48
|
+
parsed.bytes >= 0)
|
|
49
|
+
return parsed;
|
|
50
|
+
if (parsed.type === "cursor" &&
|
|
51
|
+
/^[a-f0-9]{64}$/.test(parsed.digest) &&
|
|
52
|
+
Number.isSafeInteger(parsed.fileIndex) &&
|
|
53
|
+
parsed.fileIndex >= 0 &&
|
|
54
|
+
Number.isSafeInteger(parsed.lineIndex) &&
|
|
55
|
+
parsed.lineIndex >= 0)
|
|
56
|
+
return parsed;
|
|
57
|
+
throw new Error("invalid evidence bundle handle");
|
|
58
|
+
}
|
|
59
|
+
function limit(value, fallback) {
|
|
60
|
+
const result = value ?? fallback;
|
|
61
|
+
if (!Number.isSafeInteger(result) || result < 1)
|
|
62
|
+
throw new Error("evidence bundle budgets must be positive integers");
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/** Graph associations come only from the injected adapter; source is always reread locally. */
|
|
66
|
+
export async function deliverEvidenceBundle(request, adapter) {
|
|
67
|
+
if (!Array.isArray(request.files) ||
|
|
68
|
+
request.files.length === 0 ||
|
|
69
|
+
request.files.length > MAX_FILES)
|
|
70
|
+
throw new Error(`evidence bundle requires 1-${MAX_FILES} files`);
|
|
71
|
+
if (request.level !== undefined && request.level !== "evidence" && request.level !== "expand")
|
|
72
|
+
throw new Error("invalid evidence bundle level");
|
|
73
|
+
if ((request.alreadyPresent?.length ?? 0) > MAX_FILES)
|
|
74
|
+
throw new Error("too many already-present evidence handles");
|
|
75
|
+
const initial = request.files.map((file) => normalizeEvidenceFile(request.repo, file));
|
|
76
|
+
const root = initial[0].root;
|
|
77
|
+
const requested = [...new Set(initial.map((file) => file.relative))].sort(compareBytes);
|
|
78
|
+
const graph = request.level === "expand" && adapter
|
|
79
|
+
? await adapter.relatedFiles(root, requested)
|
|
80
|
+
: {
|
|
81
|
+
files: [],
|
|
82
|
+
freshness: "unknown",
|
|
83
|
+
indexCoverage: undefined,
|
|
84
|
+
coverageNotice: undefined,
|
|
85
|
+
sourceHashes: undefined,
|
|
86
|
+
validate: undefined,
|
|
87
|
+
omissions: request.level === "expand"
|
|
88
|
+
? ["Graph adapter unavailable; related files were not selected."]
|
|
89
|
+
: [],
|
|
90
|
+
};
|
|
91
|
+
const omissions = [...graph.omissions];
|
|
92
|
+
const relations = graph.freshness === "fresh" ? graph.files : [];
|
|
93
|
+
if (graph.freshness !== "fresh" && graph.files.length)
|
|
94
|
+
omissions.push("Related files omitted because graph evidence is not fresh.");
|
|
95
|
+
const candidates = [...requested];
|
|
96
|
+
for (const relation of relations) {
|
|
97
|
+
const normalized = normalizeEvidenceFile(root, relation.file).relative;
|
|
98
|
+
if (!candidates.includes(normalized))
|
|
99
|
+
candidates.push(normalized);
|
|
100
|
+
}
|
|
101
|
+
if (candidates.length > MAX_FILES)
|
|
102
|
+
throw new RangeError(`evidence bundle exceeds ${MAX_FILES} files; narrow the selected graph scope`);
|
|
103
|
+
const key = evidenceHandleSecret(root);
|
|
104
|
+
const keyStat = fs.statSync(key.path);
|
|
105
|
+
let totalBytes = 0;
|
|
106
|
+
const contents = candidates.map((file) => {
|
|
107
|
+
const normalized = normalizeEvidenceFile(root, file);
|
|
108
|
+
const stat = fs.statSync(normalized.absolute);
|
|
109
|
+
if (stat.dev === keyStat.dev && stat.ino === keyStat.ino)
|
|
110
|
+
throw new Error("evidence bundle refuses an alias of internal knodin state");
|
|
111
|
+
totalBytes += stat.size;
|
|
112
|
+
if (totalBytes > MAX_SOURCE_BYTES)
|
|
113
|
+
throw new RangeError("evidence bundle source exceeds 8 MiB; narrow the selection");
|
|
114
|
+
const { source, bytes } = readEvidenceUtf8(normalized.absolute);
|
|
115
|
+
const contentHash = hash(source);
|
|
116
|
+
if (graph.sourceHashes && graph.sourceHashes[file] !== contentHash)
|
|
117
|
+
throw new Error("evidence bundle source changed after graph selection; restart the bundle");
|
|
118
|
+
// A concurrent append may outgrow the pre-read stat; account actual returned bytes too.
|
|
119
|
+
totalBytes += bytes - stat.size;
|
|
120
|
+
if (totalBytes > MAX_SOURCE_BYTES)
|
|
121
|
+
throw new RangeError("evidence bundle source exceeds 8 MiB; narrow the selection");
|
|
122
|
+
return {
|
|
123
|
+
file,
|
|
124
|
+
source,
|
|
125
|
+
stat,
|
|
126
|
+
lines: source.split("\n"),
|
|
127
|
+
hash: contentHash,
|
|
128
|
+
bytes,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
const manifest = contents.map((file) => ({
|
|
132
|
+
file: file.file,
|
|
133
|
+
contentHash: file.hash,
|
|
134
|
+
contentBytes: file.bytes,
|
|
135
|
+
evidenceHandle: null,
|
|
136
|
+
relations: relations.filter((relation) => normalizeEvidenceFile(root, relation.file).relative === file.file),
|
|
137
|
+
}));
|
|
138
|
+
const receipts = (request.alreadyPresent ?? []).map((value) => {
|
|
139
|
+
const receipt = verify(value, key.secret);
|
|
140
|
+
if (receipt.type !== "file-complete")
|
|
141
|
+
throw new Error("alreadyPresent requires per-file evidence handles");
|
|
142
|
+
return receipt;
|
|
143
|
+
});
|
|
144
|
+
const invalidated = receipts
|
|
145
|
+
.filter((receipt) => !contents.some((file) => file.file === receipt.file &&
|
|
146
|
+
file.hash === receipt.hash &&
|
|
147
|
+
file.bytes === receipt.bytes))
|
|
148
|
+
.map((receipt) => receipt.file);
|
|
149
|
+
const present = new Set(contents
|
|
150
|
+
.filter((file) => receipts.some((receipt) => file.file === receipt.file &&
|
|
151
|
+
file.hash === receipt.hash &&
|
|
152
|
+
file.bytes === receipt.bytes))
|
|
153
|
+
.map((file) => file.file));
|
|
154
|
+
// Bind membership, provenance, content and reuse assertions, so a cursor cannot silently skip changed evidence.
|
|
155
|
+
const manifestHash = hash(JSON.stringify({
|
|
156
|
+
requested,
|
|
157
|
+
level: request.level ?? "evidence",
|
|
158
|
+
manifest,
|
|
159
|
+
present: [...present],
|
|
160
|
+
freshness: graph.freshness,
|
|
161
|
+
indexCoverage: indexCoverageCore(graph.indexCoverage),
|
|
162
|
+
omissions,
|
|
163
|
+
}));
|
|
164
|
+
const cursor = request.continuation ? verify(request.continuation, key.secret) : undefined;
|
|
165
|
+
if (cursor && (cursor.type !== "cursor" || cursor.digest !== manifestHash))
|
|
166
|
+
throw new Error("stale or changed evidence bundle continuation; restart the bundle");
|
|
167
|
+
let fileIndex = cursor?.type === "cursor" ? cursor.fileIndex : 0;
|
|
168
|
+
let lineIndex = cursor?.type === "cursor" ? cursor.lineIndex : 0;
|
|
169
|
+
if (fileIndex > contents.length ||
|
|
170
|
+
(fileIndex < contents.length && lineIndex >= contents[fileIndex].lines.length))
|
|
171
|
+
throw new Error("invalid evidence bundle cursor range");
|
|
172
|
+
const tokenLimit = limit(request.tokenLimit, 16384);
|
|
173
|
+
const byteLimit = Math.min(limit(request.byteLimit, 65536), tokenLimit * 4);
|
|
174
|
+
const itemLimit = limit(request.itemLimit, 100);
|
|
175
|
+
const response = {
|
|
176
|
+
protocol: "knodin-evidence-bundle-v1",
|
|
177
|
+
manifest,
|
|
178
|
+
manifestHash,
|
|
179
|
+
files: [],
|
|
180
|
+
reused: [],
|
|
181
|
+
invalidated,
|
|
182
|
+
graphFreshness: graph.freshness,
|
|
183
|
+
...(graph.indexCoverage ? { indexCoverage: indexCoverageCore(graph.indexCoverage) } : {}),
|
|
184
|
+
...(graph.coverageNotice ? { coverageNotice: graph.coverageNotice } : {}),
|
|
185
|
+
sourceFreshness: "fresh",
|
|
186
|
+
omissions,
|
|
187
|
+
more: fileIndex < contents.length,
|
|
188
|
+
continuation: null,
|
|
189
|
+
budget: {
|
|
190
|
+
byteLimit,
|
|
191
|
+
tokenLimit,
|
|
192
|
+
itemLimit,
|
|
193
|
+
serializedBytes: 0,
|
|
194
|
+
estimatedTokens: 0,
|
|
195
|
+
returnedItems: 0,
|
|
196
|
+
truncated: false,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
const refresh = () => {
|
|
200
|
+
for (const [index, entry] of manifest.entries()) {
|
|
201
|
+
entry.evidenceHandle =
|
|
202
|
+
index < fileIndex || present.has(entry.file)
|
|
203
|
+
? sign({
|
|
204
|
+
type: "file-complete",
|
|
205
|
+
file: entry.file,
|
|
206
|
+
hash: entry.contentHash,
|
|
207
|
+
bytes: entry.contentBytes,
|
|
208
|
+
}, key.secret)
|
|
209
|
+
: null;
|
|
210
|
+
}
|
|
211
|
+
response.more = fileIndex < contents.length;
|
|
212
|
+
response.continuation = response.more
|
|
213
|
+
? sign({ type: "cursor", digest: manifestHash, fileIndex, lineIndex }, key.secret)
|
|
214
|
+
: null;
|
|
215
|
+
response.budget.truncated = response.more || omissions.length > 0;
|
|
216
|
+
for (let iteration = 0; iteration < 6; iteration++) {
|
|
217
|
+
response.budget.serializedBytes = Buffer.byteLength(JSON.stringify(response));
|
|
218
|
+
response.budget.estimatedTokens = Math.ceil(response.budget.serializedBytes / 4);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
refresh();
|
|
222
|
+
if (response.budget.serializedBytes > byteLimit)
|
|
223
|
+
throw new RangeError("evidence bundle manifest exceeds hard byte/token budget; increase budget or narrow files");
|
|
224
|
+
while (fileIndex < contents.length && response.budget.returnedItems < itemLimit) {
|
|
225
|
+
const file = contents[fileIndex];
|
|
226
|
+
const previousFile = fileIndex;
|
|
227
|
+
const previousLine = lineIndex;
|
|
228
|
+
const previousFiles = response.files.map((entry) => ({
|
|
229
|
+
...entry,
|
|
230
|
+
sourceRange: { ...entry.sourceRange },
|
|
231
|
+
}));
|
|
232
|
+
const previousReused = [...response.reused];
|
|
233
|
+
if (present.has(file.file)) {
|
|
234
|
+
response.reused.push(file.file);
|
|
235
|
+
fileIndex++;
|
|
236
|
+
lineIndex = 0;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
let delivered = response.files.at(-1);
|
|
240
|
+
if (!delivered || delivered.file !== file.file) {
|
|
241
|
+
delivered = {
|
|
242
|
+
file: file.file,
|
|
243
|
+
source: file.lines[lineIndex],
|
|
244
|
+
sourceRange: { startLine: lineIndex + 1, endLine: lineIndex + 1 },
|
|
245
|
+
};
|
|
246
|
+
response.files.push(delivered);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
delivered.source += `\n${file.lines[lineIndex]}`;
|
|
250
|
+
delivered.sourceRange.endLine = lineIndex + 1;
|
|
251
|
+
}
|
|
252
|
+
lineIndex++;
|
|
253
|
+
if (lineIndex === file.lines.length) {
|
|
254
|
+
fileIndex++;
|
|
255
|
+
lineIndex = 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
response.budget.returnedItems++;
|
|
259
|
+
refresh();
|
|
260
|
+
if (response.budget.serializedBytes > byteLimit) {
|
|
261
|
+
fileIndex = previousFile;
|
|
262
|
+
lineIndex = previousLine;
|
|
263
|
+
response.files = previousFiles;
|
|
264
|
+
response.reused = previousReused;
|
|
265
|
+
response.budget.returnedItems--;
|
|
266
|
+
refresh();
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Rendering can overlap external edits. Never return fresh source or reusable
|
|
271
|
+
// completion receipts for a selection that changed while this page was built.
|
|
272
|
+
await graph.validate?.();
|
|
273
|
+
for (const file of contents) {
|
|
274
|
+
const normalized = normalizeEvidenceFile(root, file.file);
|
|
275
|
+
const stat = fs.statSync(normalized.absolute);
|
|
276
|
+
if (stat.dev !== file.stat.dev ||
|
|
277
|
+
stat.ino !== file.stat.ino ||
|
|
278
|
+
stat.size !== file.stat.size ||
|
|
279
|
+
hash(readEvidenceUtf8(normalized.absolute).source) !== file.hash)
|
|
280
|
+
throw new Error("evidence bundle source changed during delivery; restart the bundle");
|
|
281
|
+
}
|
|
282
|
+
return response;
|
|
283
|
+
}
|