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
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
1
2
  function hasNonReconciliableDamage(graph) {
2
3
  if (Object.values(graph.orphaned).some((count) => count > 0))
3
4
  return true;
@@ -17,6 +18,14 @@ function unavailableState(graph) {
17
18
  return "indexing";
18
19
  if (graph.status === "indexing")
19
20
  return "indexing";
21
+ if (graph.indexCoverage?.origin === "invalid")
22
+ return "repair-needed";
23
+ if (graph.coverage.sourceFiles === 0 &&
24
+ graph.indexCoverage &&
25
+ (graph.indexCoverage.intentState !== "complete" || graph.indexCoverage.mode === "unknown"))
26
+ return "unknown";
27
+ if (graph.coverage.sourceFiles === 0 && graph.indexCoverage?.mode === "scoped")
28
+ return "empty-scope";
20
29
  if (graph.schemaVersion === 0)
21
30
  return graph.coverage.sourceFiles === 0 ? "empty-repository" : "not-initialized";
22
31
  if (graph.coverage.sourceFiles === 0)
@@ -44,6 +53,7 @@ export async function inspectGraphQueryHealth(repo, status) {
44
53
  status: "unavailable",
45
54
  state,
46
55
  graph,
56
+ ...(graph.indexCoverage ? { indexCoverage: graph.indexCoverage } : {}),
47
57
  remediation: graph.repairSteps,
48
58
  };
49
59
  }
@@ -72,9 +82,40 @@ function hasNoMatches(value) {
72
82
  return false;
73
83
  }
74
84
  /** Add an explicit healthy/no-match outcome without replacing operation data. */
75
- export function decorateGraphQueryResult(value, graphState = "healthy", freshness) {
85
+ export function decorateGraphQueryResult(value, graphState = "healthy", freshness, ...coverageObservations) {
86
+ const [indexCoverage, initialCoverage] = coverageObservations;
87
+ const ownCoverage = value && typeof value === "object" && !Array.isArray(value)
88
+ ? value.indexCoverage
89
+ : undefined;
90
+ const readCoverage = ownCoverage ?? initialCoverage;
91
+ // Omitted observations on legacy callers are not the same as an explicit
92
+ // post-read observation that lost coverage metadata. A result's own scope
93
+ // belongs to its read lease and supersedes an older preflight observation.
94
+ if (coverageObservations.length >= 1 &&
95
+ (ownCoverage !== undefined || coverageObservations.length >= 2) &&
96
+ indexCoverageSnapshot(readCoverage) !== indexCoverageSnapshot(indexCoverage))
97
+ return {
98
+ available: false,
99
+ status: "unavailable",
100
+ state: "changed-during-read",
101
+ indexCoverage,
102
+ indexCoverageAtRead: indexCoverageCore(readCoverage),
103
+ remediation: [
104
+ "Index coverage changed during the read; results were withheld. Retry against the current coverage.",
105
+ ],
106
+ };
107
+ const selectedCoverage = ownCoverage ?? indexCoverage;
108
+ const coverageFields = selectedCoverage
109
+ ? {
110
+ indexCoverage: selectedCoverage,
111
+ ...(indexCoverageNotice(selectedCoverage)
112
+ ? { coverageNotice: indexCoverageNotice(selectedCoverage) }
113
+ : {}),
114
+ }
115
+ : {};
76
116
  if (value === null || value === undefined) {
77
117
  return {
118
+ ...coverageFields,
78
119
  status: "no-match",
79
120
  result: value ?? null,
80
121
  ...(freshness ? { freshness } : {}),
@@ -83,6 +124,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
83
124
  }
84
125
  if (Array.isArray(value)) {
85
126
  return {
127
+ ...coverageFields,
86
128
  status: value.length === 0 ? "no-match" : "ok",
87
129
  results: value,
88
130
  ...(freshness ? { freshness } : {}),
@@ -95,12 +137,15 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
95
137
  }
96
138
  if (typeof value !== "object") {
97
139
  return {
140
+ ...coverageFields,
98
141
  result: value,
99
142
  ...(freshness ? { freshness } : {}),
100
143
  availability: { state: graphState, outcome: "complete" },
101
144
  };
102
145
  }
103
146
  const root = value;
147
+ if (root.available === false || root.status === "unavailable")
148
+ return { ...root, ...coverageFields };
104
149
  // An unresolved target is not a no-match: nothing was searched for. Calling
105
150
  // it `no-match` is what let a misspelled symbol read as proof that nothing
106
151
  // calls it (KNODIN-28). Ambiguity is exempted for the same reason.
@@ -109,6 +154,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
109
154
  const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
110
155
  return {
111
156
  ...root,
157
+ ...coverageFields,
112
158
  ...(freshness ? { freshness } : {}),
113
159
  ...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
114
160
  ...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
@@ -0,0 +1,69 @@
1
+ /** An explicit discovery lens, never the default for general symbol search. */
2
+ export const IMPLEMENTATION_KINDS = ["function", "method", "class", "constructor"];
3
+ /** Rank possible implementations without choosing one or turning relevance into proof. */
4
+ export async function findImplementationCandidates(engine, repo, request) {
5
+ const task = request.task.trim();
6
+ if (!task)
7
+ throw new Error("Implementation discovery requires a task");
8
+ const limit = request.limit ?? 10;
9
+ const offset = request.offset ?? 0;
10
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
11
+ throw new Error("Implementation discovery limit must be an integer from 1 to 50");
12
+ if (!Number.isSafeInteger(offset) || offset < 0)
13
+ throw new Error("Implementation discovery offset must be a nonnegative integer");
14
+ const page = await engine.search(task, repo, limit, {
15
+ kinds: [...IMPLEMENTATION_KINDS],
16
+ federate: false,
17
+ includeSource: false,
18
+ offset,
19
+ });
20
+ const unresolved = [];
21
+ if (page.semanticReadiness !== "ready")
22
+ unresolved.push(`Semantic evidence is ${page.semanticReadiness}; implementations may be missing.`);
23
+ if (page.results.some((row) => row.staleness !== "fresh" && row.staleness !== "reconciled"))
24
+ unresolved.push("Some candidate evidence is stale or has unknown freshness.");
25
+ return {
26
+ mode: "implementation-discovery",
27
+ repoPath: repo,
28
+ task,
29
+ status: unresolved.length ? "partial" : page.results.length ? "candidates" : "no-candidates",
30
+ candidates: page.results.map((row) => ({
31
+ identity: row.identity,
32
+ symbol: row.symbol,
33
+ file: row.filePath,
34
+ kind: row.kind,
35
+ rankScore: row.rrfScore,
36
+ staleness: row.staleness,
37
+ followup: {
38
+ operation: "context",
39
+ repoPath: repo,
40
+ task,
41
+ symbol: row.symbol,
42
+ identity: row.identity,
43
+ file: row.filePath,
44
+ },
45
+ })),
46
+ offset: page.offset,
47
+ limit: page.limit,
48
+ hasMore: page.hasMore,
49
+ continuation: page.hasMore
50
+ ? {
51
+ operation: "context",
52
+ contextMode: "implementation",
53
+ repoPath: repo,
54
+ task,
55
+ offset: page.offset + page.results.length,
56
+ limit,
57
+ }
58
+ : null,
59
+ retrieval: page.retrieval,
60
+ semanticReadiness: page.semanticReadiness,
61
+ unresolved,
62
+ bounds: {
63
+ includedKinds: [...IMPLEMENTATION_KINDS],
64
+ localRepositoryOnly: true,
65
+ automaticSelection: false,
66
+ note: "Ranked candidates require inspection. Types, interfaces, constants and other kinds are intentionally excluded; use ordinary search to find them. No completeness or implementation-correctness claim.",
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,33 @@
1
+ /** Bounded, semantic coverage state. Previews are presentation, not snapshot identity. */
2
+ export function indexCoverageCore(coverage) {
3
+ if (!coverage)
4
+ return undefined;
5
+ const { version, mode, revision, digest, origin, prefixCount, fileCount, intentState, repositoryComplete, negativeScope, } = coverage;
6
+ return {
7
+ version,
8
+ mode,
9
+ revision,
10
+ digest,
11
+ origin,
12
+ prefixCount,
13
+ fileCount,
14
+ intentState,
15
+ repositoryComplete,
16
+ negativeScope,
17
+ };
18
+ }
19
+ export function indexCoverageSnapshot(coverage) {
20
+ return JSON.stringify(indexCoverageCore(coverage) ?? null);
21
+ }
22
+ /** Inventory coverage cannot certify complete static relationships or runtime nonuse. */
23
+ export function indexCoverageNotice(coverage) {
24
+ if (!coverage)
25
+ return undefined;
26
+ if (coverage.intentState !== "complete" || coverage.negativeScope === "unknown")
27
+ return "Index coverage completeness is unverified, including within any declared scope; a negative graph answer does not establish absence.";
28
+ if (coverage.mode === "scoped")
29
+ return "Indexed graph evidence is limited to the declared index scope; repository-wide completeness is not established. This does not describe separate source, language-server or federated coverage.";
30
+ if (coverage.mode === "unknown" || !coverage.repositoryComplete)
31
+ return "Index coverage completeness is unverified; a negative answer does not establish repository-wide absence.";
32
+ return undefined;
33
+ }