knodin 0.12.2 → 0.13.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.
Files changed (45) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +173 -14
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/class-consumer-contract.js +18 -0
  6. package/dist/src/class-consumer-cursor.js +91 -0
  7. package/dist/src/class-consumer-delivery.js +22 -0
  8. package/dist/src/class-consumer-page.js +148 -0
  9. package/dist/src/cli-model.js +9 -3
  10. package/dist/src/docs-sections.js +1 -0
  11. package/dist/src/engine/apex-class-uses.js +430 -0
  12. package/dist/src/engine/apex-entry-points.js +98 -0
  13. package/dist/src/engine/apex-receiver.js +301 -0
  14. package/dist/src/engine/embedding-reuse.js +57 -0
  15. package/dist/src/engine/embeddings.js +22 -0
  16. package/dist/src/engine/index-coverage.js +215 -0
  17. package/dist/src/engine/index.js +2495 -252
  18. package/dist/src/engine/salesforce-components.js +460 -0
  19. package/dist/src/engine/seal.js +3 -0
  20. package/dist/src/engine/sqlite.js +44 -0
  21. package/dist/src/evidence-bundle.js +283 -0
  22. package/dist/src/evidence-graph.js +163 -0
  23. package/dist/src/failure-diagnosis.js +80 -5
  24. package/dist/src/file-dependency.js +35 -0
  25. package/dist/src/graph-query-health.js +47 -1
  26. package/dist/src/implementation-search.js +69 -0
  27. package/dist/src/index-coverage-read.js +33 -0
  28. package/dist/src/investigation.js +195 -0
  29. package/dist/src/mcp-reliability.js +4 -0
  30. package/dist/src/mcp-worker-supervisor.js +122 -6
  31. package/dist/src/progressive-evidence.js +4 -4
  32. package/dist/src/response-budget.js +129 -3
  33. package/dist/src/server.js +18 -5
  34. package/dist/src/shared-index/publisher.js +41 -1
  35. package/dist/src/tools/knodin-tools.js +224 -41
  36. package/docs/CLI.md +92 -0
  37. package/docs/MCP.md +67 -0
  38. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  39. package/docs/SALESFORCE-BINDINGS.md +121 -0
  40. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  41. package/docs/SCOPED-INDEXING.md +76 -0
  42. package/docs/apex-receiver-resolution.md +41 -0
  43. package/docs/releases/0.13.0.md +62 -0
  44. package/docs/structural-only-indexing.md +20 -0
  45. package/package.json +11 -5
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { compareBytes } from "./compare.js";
3
+ import { readEvidenceUtf8, } from "./evidence-bundle.js";
4
+ import { isFileRelationship } from "./file-dependency.js";
5
+ import { inspectGraphQueryHealth } from "./graph-query-health.js";
6
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
7
+ import { normalizeEvidenceFile } from "./progressive-evidence.js";
8
+ const MAX_EXPANSION_DEPTH = 3;
9
+ /** File dependencies from the graph, never inferred from same-name text matches. */
10
+ export function createEvidenceGraphAdapter(engine) {
11
+ return {
12
+ async relatedFiles(repo, files) {
13
+ const health = await inspectGraphQueryHealth(repo, (root) => engine.status(root));
14
+ if (!health.available)
15
+ return {
16
+ files: [],
17
+ freshness: "unknown",
18
+ omissions: [`Graph unavailable: ${health.state}.`],
19
+ indexCoverage: indexCoverageCore(health.graph?.indexCoverage),
20
+ coverageNotice: indexCoverageNotice(health.graph?.indexCoverage),
21
+ };
22
+ // Establish the query freshness lease, including supported incremental reconciliation.
23
+ await engine.query("file_summary", files[0] ?? "", repo, undefined, 1);
24
+ const status = await engine.status(repo);
25
+ const coverageAtRead = indexCoverageSnapshot(status.indexCoverage);
26
+ if (status.status !== "healthy" || status.freshness?.state !== "fresh")
27
+ return {
28
+ files: [],
29
+ freshness: "unknown",
30
+ indexCoverage: indexCoverageCore(status.indexCoverage),
31
+ coverageNotice: indexCoverageNotice(status.indexCoverage),
32
+ omissions: [
33
+ `Graph status is ${status.status}, freshness ${status.freshness?.state ?? "unknown"}; related files were not expanded.`,
34
+ ],
35
+ };
36
+ const related = [];
37
+ const omissions = [];
38
+ const coverageNotice = indexCoverageNotice(status.indexCoverage);
39
+ if (coverageNotice)
40
+ omissions.push(coverageNotice);
41
+ let fresh = true;
42
+ // Breadth-first discovery stays bounded independently of response paging.
43
+ // Include seeds in the cap so bundle delivery never receives >32 files.
44
+ const seeds = [
45
+ ...new Set(files.map((file) => normalizeEvidenceFile(repo, file).relative)),
46
+ ].sort(compareBytes);
47
+ if (seeds.length > 32)
48
+ throw new RangeError("evidence expansion requires at most 32 seeds");
49
+ const selected = new Set(seeds);
50
+ const sourceHashes = Object.create(null);
51
+ let sourceBytes = 0;
52
+ const pinSource = (file) => {
53
+ if (sourceHashes[file] !== undefined)
54
+ return;
55
+ const { source, bytes } = readEvidenceUtf8(normalizeEvidenceFile(repo, file).absolute);
56
+ sourceBytes += bytes;
57
+ if (sourceBytes > 8 * 1024 * 1024)
58
+ throw new RangeError("evidence bundle source exceeds 8 MiB; narrow the selection");
59
+ sourceHashes[file] = createHash("sha256").update(source).digest("hex");
60
+ };
61
+ for (const file of seeds)
62
+ pinSource(file);
63
+ const queue = seeds.map((file) => ({ file, depth: 0 }));
64
+ let depthBound = false;
65
+ let fileBound = false;
66
+ for (const { file, depth } of queue) {
67
+ if (depth >= MAX_EXPANSION_DEPTH) {
68
+ depthBound = true;
69
+ continue;
70
+ }
71
+ for (const pattern of ["imports_of", "importers_of"]) {
72
+ const result = await engine.query(pattern, file, repo, undefined, 32);
73
+ // Missing operation coverage is supported for older adapters, but an
74
+ // explicit mismatch invalidates the lease even after an ABA transition.
75
+ if (result.indexCoverage !== undefined &&
76
+ indexCoverageSnapshot(result.indexCoverage) !== coverageAtRead)
77
+ fresh = false;
78
+ if (result.staleness !== "fresh" || result.available === false)
79
+ fresh = false;
80
+ if (result.hasMore || result.count > result.results.length)
81
+ omissions.push(`${pattern} ${file}: only ${result.results.length} of ${result.count} relationships returned.`);
82
+ for (const row of [...result.results].sort((a, b) => compareBytes(a.file ?? "", b.file ?? "") || compareBytes(a.kind ?? "", b.kind ?? ""))) {
83
+ if (!row.file)
84
+ continue;
85
+ if (!isFileRelationship(row.kind, row.file, pattern === "importers_of")) {
86
+ omissions.push(`${pattern} ${file}: non-file relationship omitted (${row.kind ?? "unknown"}).`);
87
+ continue;
88
+ }
89
+ let normalized;
90
+ try {
91
+ normalized = normalizeEvidenceFile(repo, row.file).relative;
92
+ }
93
+ catch {
94
+ omissions.push(`${pattern} ${file}: non-file, missing or ineligible dependency omitted (${row.kind ?? "dependency"}).`);
95
+ continue;
96
+ }
97
+ if (!selected.has(normalized)) {
98
+ if (selected.size >= 32) {
99
+ fileBound = true;
100
+ continue;
101
+ }
102
+ selected.add(normalized);
103
+ pinSource(normalized);
104
+ queue.push({ file: normalized, depth: depth + 1 });
105
+ }
106
+ related.push({
107
+ file: normalized,
108
+ identity: row.identity,
109
+ relation: `${pattern}:${file}:${row.kind ?? "dependency"}`,
110
+ confidence: "heuristic",
111
+ // Dependency queries do not define row.line as a declaration location.
112
+ // In particular, an incoming edge is declared in the importer, not `file`.
113
+ });
114
+ }
115
+ }
116
+ }
117
+ if (depthBound)
118
+ omissions.push(`Expansion stopped at ${MAX_EXPANSION_DEPTH} dependency hops; the remaining frontier was not inspected.`);
119
+ if (fileBound)
120
+ omissions.push("Expansion limited to 32 files including seeds; additional related files were omitted.");
121
+ const after = await engine.status(repo);
122
+ // Probe timestamps are observational; compare source generation and fingerprints only.
123
+ const generationChanged = coverageAtRead !== indexCoverageSnapshot(after.indexCoverage) ||
124
+ status.indexGeneration !== after.indexGeneration ||
125
+ status.freshness?.currentHead !== after.freshness?.currentHead ||
126
+ status.freshness?.indexedHead !== after.freshness?.indexedHead ||
127
+ status.freshness?.workingTree?.indexedFingerprint !==
128
+ after.freshness?.workingTree?.indexedFingerprint;
129
+ if (generationChanged || after.status !== "healthy" || after.freshness?.state !== "fresh")
130
+ fresh = false;
131
+ if (!fresh)
132
+ omissions.push("Graph changed or became stale during evidence expansion; relationships were withheld.");
133
+ const validate = async () => {
134
+ const current = await engine.status(repo);
135
+ if (!fresh ||
136
+ current.status !== "healthy" ||
137
+ current.freshness?.state !== "fresh" ||
138
+ coverageAtRead !== indexCoverageSnapshot(current.indexCoverage) ||
139
+ status.indexGeneration !== current.indexGeneration ||
140
+ status.freshness?.currentHead !== current.freshness?.currentHead ||
141
+ status.freshness?.indexedHead !== current.freshness?.indexedHead ||
142
+ status.freshness?.workingTree?.indexedFingerprint !==
143
+ current.freshness?.workingTree?.indexedFingerprint)
144
+ throw new Error("Graph changed during evidence delivery; restart the bundle");
145
+ };
146
+ // Validate the raw source pins as well as observational graph metadata.
147
+ // These are selected-file snapshots, not a claim that every indexed byte was checked.
148
+ for (const [file, expected] of Object.entries(sourceHashes)) {
149
+ const actual = readEvidenceUtf8(normalizeEvidenceFile(repo, file).absolute).source;
150
+ if (createHash("sha256").update(actual).digest("hex") !== expected)
151
+ throw new Error("Source changed during evidence expansion; restart the bundle");
152
+ }
153
+ return {
154
+ files: fresh ? related : [],
155
+ freshness: fresh ? "fresh" : "stale",
156
+ omissions,
157
+ indexCoverage: indexCoverageCore(after.indexCoverage),
158
+ coverageNotice: indexCoverageNotice(after.indexCoverage),
159
+ ...(fresh ? { sourceHashes, validate } : {}),
160
+ };
161
+ },
162
+ };
163
+ }
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { gitExecutable } from "./git-executable.js";
5
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
5
6
  import { compressOutput, readOutputArtifact } from "./output-compression.js";
6
7
  const CODE_EXTENSIONS = [
7
8
  ".tsx",
@@ -552,6 +553,15 @@ function confidenceLevel(diagnosticCount, status) {
552
553
  return "refused";
553
554
  return status === "resolved" ? "exact" : "qualified";
554
555
  }
556
+ function diagnosisSnapshot(health) {
557
+ return JSON.stringify([
558
+ health.indexGeneration,
559
+ health.freshness.currentHead,
560
+ health.freshness.indexedHead,
561
+ health.freshness.workingTree.indexedFingerprint,
562
+ indexCoverageSnapshot(health.indexCoverage),
563
+ ]);
564
+ }
555
565
  export async function diagnoseFailure(graph, repo, request) {
556
566
  const maxDiagnostics = integer("maxDiagnostics", request.maxDiagnostics, DEFAULT_DIAGNOSTICS, 1, MAX_DIAGNOSTICS);
557
567
  const diagnosticOffset = integer("diagnosticOffset", request.diagnosticOffset, 0, 0, 1_000_000);
@@ -560,6 +570,38 @@ export async function diagnoseFailure(graph, repo, request) {
560
570
  const relationLimit = integer("relationLimit", request.relationLimit, DEFAULT_RELATIONS, 1, MAX_RELATIONS);
561
571
  const recentCommitLimit = integer("recentCommitLimit", request.recentCommitLimit, 3, 0, 10);
562
572
  const input = diagnosisSource(repo, request);
573
+ // A cold read can legitimately reconcile. Establish it before capturing the
574
+ // lease, then keep every diagnostic and relation within that same observation.
575
+ await graph.query("file_summary", "", repo, undefined, 1);
576
+ const before = await graph.status(repo, { audit: "cached" });
577
+ const beforeSnapshot = diagnosisSnapshot(before);
578
+ const coverageAtRead = indexCoverageSnapshot(before.indexCoverage);
579
+ let observationChanged = false;
580
+ let staleObservation = false;
581
+ let missingQueryCoverage = false;
582
+ const observedGraph = {
583
+ status: (...args) => graph.status(...args),
584
+ query: async (...args) => {
585
+ const result = await graph.query(...args);
586
+ // Explicit observations catch changed-and-restored scope as well as
587
+ // final drift. Legacy adapters may omit this field; never invent it.
588
+ missingQueryCoverage ||= result.indexCoverage === undefined;
589
+ if (result.indexCoverage !== undefined)
590
+ observationChanged ||= indexCoverageSnapshot(result.indexCoverage) !== coverageAtRead;
591
+ staleObservation ||= !["fresh", "reconciled"].includes(result.staleness ?? "unknown");
592
+ return result;
593
+ },
594
+ search: async (...args) => {
595
+ missingQueryCoverage = true; // SearchPage does not expose a scope observation.
596
+ const start = await graph.status(repo, { audit: "cached" });
597
+ const result = await graph.search(...args);
598
+ const end = await graph.status(repo, { audit: "cached" });
599
+ observationChanged ||=
600
+ diagnosisSnapshot(start) !== beforeSnapshot || diagnosisSnapshot(end) !== beforeSnapshot;
601
+ staleObservation ||= result.results.some((row) => row.staleness !== undefined && !["fresh", "reconciled"].includes(row.staleness));
602
+ return result;
603
+ },
604
+ };
563
605
  const detectedReferences = extractDiagnosticReferences(input.content, Number.MAX_SAFE_INTEGER);
564
606
  const references = detectedReferences.slice(diagnosticOffset, diagnosticOffset + maxDiagnostics);
565
607
  const omittedDiagnostics = Math.max(0, detectedReferences.length - diagnosticOffset - references.length);
@@ -571,7 +613,7 @@ export async function diagnoseFailure(graph, repo, request) {
571
613
  let omittedRelations = 0;
572
614
  let omittedContextBytes = 0;
573
615
  for (const reference of references) {
574
- const resolved = await resolveReferencePath(graph, repo, reference);
616
+ const resolved = await resolveReferencePath(observedGraph, repo, reference);
575
617
  if (!resolved.file) {
576
618
  unresolved.push({
577
619
  reference,
@@ -580,7 +622,7 @@ export async function diagnoseFailure(graph, repo, request) {
580
622
  });
581
623
  continue;
582
624
  }
583
- const resolvedDiagnosis = await diagnoseResolvedReference(graph, repo, reference, resolved.file, {
625
+ const resolvedDiagnosis = await diagnoseResolvedReference(observedGraph, repo, reference, resolved.file, {
584
626
  relationLimit,
585
627
  recentCommitLimit,
586
628
  contextLines,
@@ -596,7 +638,21 @@ export async function diagnoseFailure(graph, repo, request) {
596
638
  diagnostics.push(resolvedDiagnosis.diagnostic);
597
639
  }
598
640
  const health = await graph.status(repo, { audit: "cached" });
599
- const status = diagnosisStatus(diagnostics.length, unresolved.length + omittedDiagnostics, input.complete, contextTruncated, health.freshness.state);
641
+ const changed = observationChanged || beforeSnapshot !== diagnosisSnapshot(health);
642
+ if (changed) {
643
+ // Do not leave either graph-derived negatives or source snippets beside
644
+ // a refusal: consumers can otherwise mistake them for a coherent result.
645
+ diagnostics.length = 0;
646
+ unresolved.length = 0;
647
+ snippets.length = 0;
648
+ contextBytes = 0;
649
+ contextTruncated = false;
650
+ omittedRelations = 0;
651
+ omittedContextBytes = 0;
652
+ }
653
+ let status = diagnosisStatus(diagnostics.length, unresolved.length + omittedDiagnostics, input.complete, contextTruncated, health.freshness.state);
654
+ if (staleObservation && status === "resolved")
655
+ status = "partial";
600
656
  const omissionReasons = [];
601
657
  if (omittedDiagnostics > 0)
602
658
  omissionReasons.push("diagnostic-item-budget");
@@ -607,6 +663,8 @@ export async function diagnoseFailure(graph, repo, request) {
607
663
  if (!input.complete)
608
664
  omissionReasons.push("input-byte-budget");
609
665
  const confidenceReasons = [
666
+ ...(changed ? ["changed-during-diagnosis"] : []),
667
+ ...(staleObservation ? ["stale-query-evidence"] : []),
610
668
  ...(diagnostics.length === 0 ? ["no-safe-source-reference"] : []),
611
669
  ...(unresolved.length > 0 ? ["unresolved-source-reference"] : []),
612
670
  ...omissionReasons,
@@ -615,6 +673,11 @@ export async function diagnoseFailure(graph, repo, request) {
615
673
  const returnedRelations = diagnostics.reduce((total, diagnostic) => total + diagnostic.tests.length + diagnostic.upstream.length + diagnostic.downstream.length, 0);
616
674
  return {
617
675
  schemaVersion: 1,
676
+ ...(changed ? { available: false, state: "changed-during-diagnosis" } : {}),
677
+ ...(before.indexCoverage ? { indexCoverage: indexCoverageCore(before.indexCoverage) } : {}),
678
+ ...(indexCoverageNotice(before.indexCoverage)
679
+ ? { coverageNotice: indexCoverageNotice(before.indexCoverage) }
680
+ : {}),
618
681
  status,
619
682
  input: {
620
683
  kind: input.kind,
@@ -640,7 +703,7 @@ export async function diagnoseFailure(graph, repo, request) {
640
703
  relations: omittedRelations,
641
704
  contextBytes: omittedContextBytes,
642
705
  reasons: omissionReasons,
643
- continuation: omittedDiagnostics > 0 && input.artifactId
706
+ continuation: !changed && omittedDiagnostics > 0 && input.artifactId
644
707
  ? { artifactId: input.artifactId, nextDiagnostic: diagnosticOffset + references.length }
645
708
  : null,
646
709
  },
@@ -656,6 +719,18 @@ export async function diagnoseFailure(graph, repo, request) {
656
719
  },
657
720
  returned: { diagnostics: diagnostics.length, relations: returnedRelations, contextBytes },
658
721
  },
659
- limitations: diagnosisLimitations(input.complete, references.length, contextTruncated, health.freshness.state),
722
+ limitations: [
723
+ ...diagnosisLimitations(input.complete, references.length, contextTruncated, health.freshness.state),
724
+ ...(changed
725
+ ? [
726
+ "Graph generation or index coverage changed during diagnosis; mixed evidence was withheld. Retry against the current source and scope.",
727
+ ]
728
+ : []),
729
+ ...(missingQueryCoverage
730
+ ? [
731
+ "Some adapter queries omit per-operation coverage; surrounding status observations cannot detect a scope change that is restored entirely within such a query.",
732
+ ]
733
+ : []),
734
+ ],
660
735
  };
661
736
  }
@@ -0,0 +1,35 @@
1
+ // Dependency rows also contain resource names and bundle directories. Only
2
+ // positively identified file relationships may select source for disclosure.
3
+ export function isFileRelationship(kind, file, incoming) {
4
+ if (kind === "import" || kind === "lsif_import" || kind === "scip_import")
5
+ return true;
6
+ if (kind === "flow_apex_action")
7
+ return incoming ? file.endsWith(".flow-meta.xml") : file.endsWith(".cls");
8
+ if (kind === "flow_screen_component")
9
+ return incoming ? file.endsWith(".flow-meta.xml") : /\.(?:cmp|js)$/.test(file);
10
+ if (kind === "aura_resource_component")
11
+ return incoming ? file.endsWith(".app") : /\.(?:cmp|js)$/.test(file);
12
+ if (kind === "visualforce_lightning_out")
13
+ return incoming ? file.endsWith(".page") : file.endsWith(".app");
14
+ if (kind === "weblink_visualforce_page")
15
+ return incoming ? file.endsWith(".webLink-meta.xml") : file.endsWith(".page");
16
+ if (kind === "quickaction_lightning_component")
17
+ return incoming ? file.endsWith(".quickAction-meta.xml") : file.endsWith(".cmp");
18
+ if (kind === "appbuilder_apex_datasource")
19
+ return incoming ? /(?:\.js-meta\.xml|\.design)$/.test(file) : file.endsWith(".cls");
20
+ if (kind === "aura_component")
21
+ return incoming ? /\.(?:cmp|app)$/.test(file) : file.endsWith(".cmp");
22
+ if (kind === "lwc_template_component")
23
+ return incoming ? file.endsWith(".html") : file.endsWith(".js");
24
+ if (kind === "flexipage_component")
25
+ return incoming ? file.endsWith(".flexipage-meta.xml") : /\.(?:cmp|js)$/.test(file);
26
+ if (kind === "flexipage_template")
27
+ return incoming ? file.endsWith(".flexipage-meta.xml") : file.endsWith(".cmp");
28
+ if (kind === "aura_lwc_component" && incoming && file.endsWith(".app"))
29
+ return true;
30
+ if (kind === "apex_method" || kind === "aura_apex_controller" || kind === "controller")
31
+ return incoming ? /\.(?:js|ts|cmp|page|component)$/.test(file) : file.endsWith(".cls");
32
+ if (kind === "lwc_component" || kind === "aura_lwc_component")
33
+ return /\.(?:js|ts|html|cmp)$/.test(file);
34
+ return false;
35
+ }