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.
Files changed (58) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +245 -18
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/backup-retention.js +345 -12
  6. package/dist/src/class-consumer-contract.js +18 -0
  7. package/dist/src/class-consumer-cursor.js +91 -0
  8. package/dist/src/class-consumer-delivery.js +22 -0
  9. package/dist/src/class-consumer-page.js +148 -0
  10. package/dist/src/cli-model.js +21 -3
  11. package/dist/src/docs-sections.js +1 -0
  12. package/dist/src/engine/apex-class-uses.js +430 -0
  13. package/dist/src/engine/apex-entry-points.js +98 -0
  14. package/dist/src/engine/apex-receiver.js +301 -0
  15. package/dist/src/engine/candidate-database.js +624 -66
  16. package/dist/src/engine/embedding-reuse.js +57 -0
  17. package/dist/src/engine/embeddings.js +22 -0
  18. package/dist/src/engine/index-coverage.js +215 -0
  19. package/dist/src/engine/index.js +3854 -448
  20. package/dist/src/engine/salesforce-components.js +460 -0
  21. package/dist/src/engine/seal.js +3 -0
  22. package/dist/src/engine/sqlite.js +44 -0
  23. package/dist/src/engine/state-paths.js +192 -31
  24. package/dist/src/evidence-bundle.js +283 -0
  25. package/dist/src/evidence-graph.js +163 -0
  26. package/dist/src/failure-diagnosis.js +80 -5
  27. package/dist/src/file-dependency.js +35 -0
  28. package/dist/src/graph-query-health.js +47 -1
  29. package/dist/src/implementation-search.js +69 -0
  30. package/dist/src/index-coverage-read.js +33 -0
  31. package/dist/src/investigation.js +195 -0
  32. package/dist/src/manager-update.js +9 -10
  33. package/dist/src/mcp-reliability.js +4 -0
  34. package/dist/src/mcp-worker-supervisor.js +122 -6
  35. package/dist/src/mirror.js +2 -0
  36. package/dist/src/progressive-evidence.js +4 -4
  37. package/dist/src/response-budget.js +129 -3
  38. package/dist/src/server.js +18 -5
  39. package/dist/src/shared-index/publisher.js +41 -1
  40. package/dist/src/shared-index/restore.js +16 -6
  41. package/dist/src/storage-budget.js +146 -0
  42. package/dist/src/storage-inventory.js +263 -0
  43. package/dist/src/storage-management.js +204 -0
  44. package/dist/src/storage-policy-contract.js +121 -0
  45. package/dist/src/tools/knodin-tools.js +224 -41
  46. package/dist/src/worktree-seed.js +7 -1
  47. package/docs/BACKUP-RETENTION.md +46 -2
  48. package/docs/CLI.md +98 -2
  49. package/docs/MCP.md +75 -0
  50. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  51. package/docs/SALESFORCE-BINDINGS.md +121 -0
  52. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  53. package/docs/SCOPED-INDEXING.md +76 -0
  54. package/docs/apex-receiver-resolution.md +41 -0
  55. package/docs/releases/0.13.0.md +62 -0
  56. package/docs/releases/0.13.1.md +216 -0
  57. package/docs/structural-only-indexing.md +20 -0
  58. 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 path.join(mirrorRoot(), ".staging");
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
- return path.join(mirrorRoot(), identity);
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 path.join(mirrorEntryPath(identity), MIRROR_SOURCE_DIR);
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 path.join(mirrorEntryPath(identity), MIRROR_STATE_DIR);
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 key is (mtimeMs, size), which is a heuristic rather than a guarantee:
89
- * an out-of-process write landing in the same millisecond AND producing the same
90
- * byte length would not invalidate it. Accepted deliberately — that requires a
91
- * concurrent edit of identical length within one millisecond, the in-process
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
- * A missing or corrupt registry reads as empty. That is deliberate too: it
96
- * degrades to "no mirrors configured", which routes every repository to its
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.statSync(file);
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
- return EMPTY_REGISTRY;
152
+ throw new Error("mirror registry: unsafe or oversized registry");
109
153
  }
110
- if (cache?.mtimeMs === stat.mtimeMs && cache?.size === stat.size)
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 parsed = JSON.parse(fs.readFileSync(file, "utf8"));
114
- const mirrors = typeof parsed === "object" &&
115
- parsed !== null &&
116
- Array.isArray(parsed.mirrors)
117
- ? parsed.mirrors.filter(isMirrorRecord)
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 = { mtimeMs: stat.mtimeMs, size: stat.size, registry, byPath: indexByPath(registry) };
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
- return EMPTY_REGISTRY;
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
- fs.mkdirSync(path.dirname(file), { recursive: true });
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
- fs.writeFileSync(file, `${JSON.stringify(document, null, 2)}\n`);
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
- return cache?.byPath.get(path.resolve(repoPath));
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
+ }