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
@@ -10,6 +10,7 @@
10
10
  *
11
11
  * This file implements the Phase 1: SQLite + Tree-Sitter Core engine.
12
12
  */
13
+ import { AsyncLocalStorage } from "node:async_hooks";
13
14
  import child_process from "node:child_process";
14
15
  import crypto from "node:crypto";
15
16
  import fs from "node:fs";
@@ -18,21 +19,31 @@ import path from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import chokidar from "chokidar";
20
21
  import Parser from "web-tree-sitter";
22
+ import { ClassConsumerQueryError, } from "../class-consumer-contract.js";
23
+ import { readClassConsumerCursor } from "../class-consumer-cursor.js";
24
+ import { finalizeClassConsumerPage } from "../class-consumer-page.js";
21
25
  import { compareBytes } from "../compare.js";
26
+ import { isFileRelationship } from "../file-dependency.js";
22
27
  import { collectFileMetrics } from "../file-metrics.js";
23
28
  import { readIndexActivity } from "../index-activity.js";
24
29
  import { runReadOnlyLspQuery } from "../lsp-readonly.js";
30
+ import { evidenceHandleSecret } from "../progressive-evidence.js";
25
31
  import { acquireRepairLease } from "../repair-lease.js";
26
32
  import { analyzeResourceReachability, resourceFingerprint, } from "../resource-reachability.js";
27
33
  import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snapshot.js";
28
34
  import { acquireLifecycleCoordination } from "../update-coordination.js";
29
35
  import { KNODIN_VERSION } from "../version.js";
30
36
  import * as ann from "./ann-hnsw.js";
37
+ import { createApexConsumerCatalog, extractApexClassUses, resolveApexClassUses, } from "./apex-class-uses.js";
38
+ import { apexOrgEntryPoints } from "./apex-entry-points.js";
39
+ import { apexCall, apexDeclaration, apexReceiverCapability, apexReferenceReadGuard, parseApexDeclaration, persistApexReferenceEndpoints, } from "./apex-receiver.js";
31
40
  import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, sweepAbandonedCandidates, } from "./candidate-database.js";
32
- import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
41
+ import { EmbeddingReuseCache, embeddingReuseKey } from "./embedding-reuse.js";
42
+ import { computeSimilarity, generateEmbedding, generateEmbeddings, getEmbeddingCacheIdentity, } from "./embeddings.js";
33
43
  import { walkRepoFiles } from "./file-walker.js";
34
44
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
35
45
  import { isLinkedWorktree } from "./git-layout.js";
46
+ import { canonicalCoveragePath, coverageContains, coverageGitPathspecBatches, coverageMayContain, INDEX_COVERAGE_COMPLETE_KEY, INDEX_COVERAGE_KEY, INDEX_COVERAGE_PENDING_KEY, indexCoverageSummary, mergeIndexCoverage, readIndexCoverage, requireIndexCoverage, } from "./index-coverage.js";
36
47
  // Runtime import, but not a cycle: parse-pool imports only TYPES from here,
37
48
  // which erase at compile time. parse-worker's runtime import of this module
38
49
  // resolves inside the worker thread, never in this one.
@@ -40,6 +51,7 @@ import { createParsePool, parseWorkersEnabled } from "./parse-pool.js";
40
51
  import { beginPerfPhase, measurePerfPhase, measurePerfPhaseSync } from "./perf.js";
41
52
  import { isIndexablePath, makeWatchIgnorePredicate } from "./prune.js";
42
53
  import { reflinkCopyFile } from "./reflink-copy.js";
54
+ import { componentBindings, componentCatalog, visualforceInlineScripts, } from "./salesforce-components.js";
43
55
  import { readSarifLog, SARIF_DEFAULT_LIMITS, } from "./sarif-import.js";
44
56
  import { readScipIndex, SCIP_DEFAULT_LIMITS, } from "./scip-import.js";
45
57
  import { isSealedDatabase, listSealedFiles, readSealedSource } from "./seal.js";
@@ -511,7 +523,7 @@ function referencesForFiles(db, filePaths) {
511
523
  }
512
524
  return [...rows.values()];
513
525
  }
514
- function persistSymbolIdentities(db, repoPath, filePaths) {
526
+ function persistSymbolIdentities(db, repoPath, filePaths, deferApex = false) {
515
527
  const scope = scopedFilePaths(filePaths);
516
528
  const rows = symbolsForFiles(db, scope);
517
529
  const update = db.query("UPDATE symbols SET identity = ? WHERE id = ?");
@@ -579,7 +591,7 @@ function persistSymbolIdentities(db, repoPath, filePaths) {
579
591
  update.run(identity, row.id);
580
592
  }
581
593
  update.finalize();
582
- persistReferenceEndpoints(db, scope);
594
+ persistReferenceEndpoints(db, scope, deferApex);
583
595
  db.run("RELEASE persist_symbol_identities;");
584
596
  }
585
597
  catch (error) {
@@ -599,15 +611,26 @@ async function persistSymbolIdentitiesCancellable(db, repoPath, filePaths, signa
599
611
  : (scopedFilePaths(filePaths) ?? []);
600
612
  for (const file of scope) {
601
613
  throwIfAborted(signal);
602
- persistSymbolIdentities(db, repoPath, [file]);
614
+ persistSymbolIdentities(db, repoPath, [file], true);
603
615
  await yieldToIndexEventLoop();
604
616
  }
617
+ if (scope.some((file) => /\.(?:cls|trigger)$/i.test(file))) {
618
+ persistApexReferenceEndpoints(db);
619
+ persistApexClassConsumerEndpoints(db);
620
+ }
605
621
  }
606
622
  /** Bind name/file reference rows to exact definitions after identities exist. */
607
- function persistReferenceEndpoints(db, filePaths) {
623
+ function persistReferenceEndpoints(db, filePaths, deferApex = false) {
624
+ const resolveApex = !deferApex &&
625
+ (filePaths === undefined || filePaths.some((file) => /\.(?:cls|trigger)$/i.test(file)));
608
626
  const refs = referencesForFiles(db, filePaths);
609
- if (refs.length === 0)
627
+ if (refs.length === 0) {
628
+ if (resolveApex) {
629
+ persistApexReferenceEndpoints(db);
630
+ persistApexClassConsumerEndpoints(db);
631
+ }
610
632
  return;
633
+ }
611
634
  const definitionFiles = filePaths === undefined
612
635
  ? undefined
613
636
  : [
@@ -670,7 +693,7 @@ function persistReferenceEndpoints(db, filePaths) {
670
693
  const caller = choose(ref.callerSymbol, ref.callerFile, ref.line);
671
694
  // Never invent an endpoint for an ambiguous name/file pair. Parsers can
672
695
  // later provide a qualified identity; until then ambiguity remains explicit.
673
- const callee = ref.calleeFile
696
+ const callee = ref.calleeFile && !ref.apexCall
674
697
  ? chooseScopedCallee(ref.calleeSymbol, ref.calleeFile, ref.line)
675
698
  : undefined;
676
699
  const callerIdentity = caller?.identity ?? null;
@@ -680,6 +703,835 @@ function persistReferenceEndpoints(db, filePaths) {
680
703
  }
681
704
  }
682
705
  update.finalize();
706
+ if (resolveApex) {
707
+ persistApexReferenceEndpoints(db);
708
+ persistApexClassConsumerEndpoints(db);
709
+ }
710
+ }
711
+ const APEX_CONSUMER_BINDING_VERSION = "2-interface-types";
712
+ /** Classes retain receiver provenance; interfaces use their independent parser span proof. */
713
+ function apexConsumerSymbolOwner(db, symbol, cache) {
714
+ if (symbol.kind === "class" &&
715
+ !cache
716
+ .get(symbol.filePath)
717
+ ?.declarations.some((declaration) => declaration.kind === "class" &&
718
+ declaration.name === symbol.name &&
719
+ declaration.declarationRange?.start.line === symbol.startLine &&
720
+ declaration.declarationRange.start.column - 1 === symbol.startCol))
721
+ return parseApexDeclaration(symbol.apexDeclaration)?.owner ?? null;
722
+ if (symbol.kind !== "interface" && symbol.kind !== "class")
723
+ return null;
724
+ if (!cache.has(symbol.filePath)) {
725
+ const row = db
726
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath WHERE f.filePath=?")
727
+ .get(symbol.filePath);
728
+ cache.set(symbol.filePath, row ? readApexConsumerFacts(row) : null);
729
+ }
730
+ const facts = cache.get(symbol.filePath);
731
+ if (facts?.parseState !== "complete")
732
+ return null;
733
+ const matches = facts.declarations.filter((declaration) => {
734
+ const range = declaration.declarationRange;
735
+ return (declaration.kind === symbol.kind &&
736
+ declaration.name === symbol.name &&
737
+ range &&
738
+ range.start.line === symbol.startLine &&
739
+ range.start.column - 1 === symbol.startCol &&
740
+ range.end.line === symbol.endLine &&
741
+ range.end.column - 1 === symbol.endCol);
742
+ });
743
+ return matches.length === 1 ? matches[0].ownerPath : null;
744
+ }
745
+ const apexConsumerKinds = new Set([
746
+ "static_field_read",
747
+ "constructor",
748
+ "variable_type",
749
+ "cast",
750
+ "class_literal",
751
+ "parameter_type",
752
+ "generic_parameter_type",
753
+ "loop_variable_type",
754
+ "generic_type_argument",
755
+ ]);
756
+ function validConsumerPosition(value) {
757
+ if (!value || typeof value !== "object")
758
+ return false;
759
+ const p = value;
760
+ return ["line", "column", "utf16Offset", "byte"].every((key) => Number.isSafeInteger(p[key]) &&
761
+ p[key] >= (key === "line" || key === "column" ? 1 : 0));
762
+ }
763
+ function validConsumerSite(value) {
764
+ if (!value || typeof value !== "object")
765
+ return false;
766
+ const site = value;
767
+ return (site.version === 1 &&
768
+ apexConsumerKinds.has(site.kind) &&
769
+ typeof site.qualifiedSpelling === "string" &&
770
+ typeof site.typePath === "string" &&
771
+ /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(site.typePath) &&
772
+ (site.memberName === null || typeof site.memberName === "string") &&
773
+ (site.lexicalOwner === null || typeof site.lexicalOwner === "string") &&
774
+ [null, "value-shadow", "malformed-source"].includes(site.blockedReason) &&
775
+ validConsumerPosition(site.start) &&
776
+ validConsumerPosition(site.end) &&
777
+ site.end.byte > site.start.byte &&
778
+ site.end.utf16Offset > site.start.utf16Offset);
779
+ }
780
+ function readApexConsumerFacts(row) {
781
+ if (row.version !== 1 ||
782
+ row.facts.length > 32 * 1024 * 1024 ||
783
+ crypto.createHash("sha256").update(row.facts).digest("hex") !== row.factsHash)
784
+ return null;
785
+ try {
786
+ const facts = JSON.parse(row.facts);
787
+ if (facts.heritageComplete !== undefined && typeof facts.heritageComplete !== "boolean")
788
+ return null;
789
+ if (facts.heritage !== undefined &&
790
+ (!Array.isArray(facts.heritage) ||
791
+ !facts.heritage.every((entry) => ["extends", "implements"].includes(entry.kind) &&
792
+ typeof entry.ownerPath === "string" &&
793
+ typeof entry.typePath === "string" &&
794
+ /^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(entry.typePath) &&
795
+ validConsumerPosition(entry.start) &&
796
+ validConsumerPosition(entry.end) &&
797
+ entry.start.byte < entry.end.byte)))
798
+ return null;
799
+ if ("omissionReason" in facts &&
800
+ (!["source-size-limit", "parser-unavailable", "parser-failure"].includes(String(facts.omissionReason)) ||
801
+ facts.parseState !== "partial" ||
802
+ facts.isTest !== null ||
803
+ !Array.isArray(facts.sites) ||
804
+ facts.sites.length !== 0 ||
805
+ !Array.isArray(facts.declarations) ||
806
+ facts.declarations.length !== 0 ||
807
+ !Array.isArray(facts.unsupportedUses) ||
808
+ facts.unsupportedUses.length !== 0))
809
+ return null;
810
+ if (facts.version !== 1 ||
811
+ facts.file !== row.filePath ||
812
+ facts.sourceHash !== row.sourceHash ||
813
+ !/^[a-f0-9]{64}$/.test(facts.sourceHash) ||
814
+ facts.parseState !== row.parseState ||
815
+ !["complete", "partial"].includes(facts.parseState) ||
816
+ facts.coverage !== "supported-syntax-only" ||
817
+ !(facts.isTest === null || typeof facts.isTest === "boolean") ||
818
+ (facts.isTest === null ? null : facts.isTest ? 1 : 0) !== row.isTest ||
819
+ !Array.isArray(facts.sites) ||
820
+ !facts.sites.every(validConsumerSite) ||
821
+ !Array.isArray(facts.declarations) ||
822
+ !Array.isArray(facts.unsupportedUses))
823
+ return null;
824
+ if (!facts.declarations.every((decl) => decl.version === 1 &&
825
+ ["class", "interface", "enum", "field", "property"].includes(decl.kind) &&
826
+ typeof decl.ownerPath === "string" &&
827
+ typeof decl.name === "string" &&
828
+ typeof decl.static === "boolean" &&
829
+ (decl.superclass === null || typeof decl.superclass === "string") &&
830
+ Array.isArray(decl.annotations) &&
831
+ decl.annotations.every((a) => typeof a === "string") &&
832
+ validConsumerPosition(decl.start) &&
833
+ validConsumerPosition(decl.end) &&
834
+ (decl.declarationRange === undefined ||
835
+ (validConsumerPosition(decl.declarationRange.start) &&
836
+ validConsumerPosition(decl.declarationRange.end) &&
837
+ decl.declarationRange.start.byte <= decl.start.byte &&
838
+ decl.declarationRange.end.byte >= decl.end.byte))))
839
+ return null;
840
+ return facts;
841
+ }
842
+ catch {
843
+ return null;
844
+ }
845
+ }
846
+ /** Rebind retained typed facts after identities exist, without reading repository source. */
847
+ function apexConsumerMaterializationDigest(db) {
848
+ const hash = crypto.createHash("sha256");
849
+ let after;
850
+ for (;;) {
851
+ const rows = db
852
+ .query(`SELECT filePath,startByte,endByte,kind,raw,status,targetFile,targetOwner,targetIdentity,ownerIdentity,isTest FROM apex_class_uses ${after ? "WHERE (filePath COLLATE BINARY,startByte,endByte,kind COLLATE BINARY)>(? COLLATE BINARY,?,?,? COLLATE BINARY)" : ""} ORDER BY filePath COLLATE BINARY,startByte,endByte,kind COLLATE BINARY LIMIT 500`)
853
+ .all(...(after ? [after.filePath, after.startByte, after.endByte, after.kind] : []));
854
+ for (const row of rows)
855
+ hash.update(JSON.stringify(row)).update("\n");
856
+ if (rows.length < 500)
857
+ break;
858
+ after = rows[rows.length - 1];
859
+ }
860
+ return hash.digest("hex");
861
+ }
862
+ function apexConsumerMaterializationValid(db) {
863
+ if (db
864
+ .query("SELECT COUNT(*) count FROM sqlite_master WHERE type='table' AND name IN ('apex_class_uses','apex_consumer_files')")
865
+ .get()?.count !== 2)
866
+ return false;
867
+ const stored = db
868
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath")
869
+ .all();
870
+ const facts = stored.map(readApexConsumerFacts);
871
+ if (facts.includes(null))
872
+ return false;
873
+ const expected = facts.reduce((sum, fact) => sum + (fact?.sites.length ?? 0), 0);
874
+ if ((db.query("SELECT COUNT(*) count FROM apex_class_uses").get()?.count ??
875
+ 0) !== expected)
876
+ return false;
877
+ return Boolean(getMeta(db, "apexConsumerBindingVersion") === APEX_CONSUMER_BINDING_VERSION &&
878
+ getMeta(db, "apexConsumerFactsRevision") &&
879
+ getMeta(db, "apexConsumerFactsRevision") === getMeta(db, "apexConsumerBoundRevision") &&
880
+ getMeta(db, "apexConsumerMaterializationDigest") === apexConsumerMaterializationDigest(db));
881
+ }
882
+ function persistApexClassConsumerEndpoints(db) {
883
+ const stored = db
884
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath")
885
+ .all();
886
+ const facts = stored
887
+ .map(readApexConsumerFacts)
888
+ .filter((fact) => fact !== null);
889
+ const catalog = createApexConsumerCatalog(facts);
890
+ const symbols = db
891
+ .query("SELECT * FROM symbols WHERE kind IN ('class','interface')")
892
+ .all();
893
+ const factsByFile = new Map(facts.map((file) => [file.file, file]));
894
+ const identities = new Map();
895
+ for (const symbol of symbols) {
896
+ const owner = apexConsumerSymbolOwner(db, symbol, factsByFile);
897
+ if (!owner || !symbol.identity)
898
+ continue;
899
+ const key = `${symbol.filePath}\0${owner.toLowerCase()}`;
900
+ const entries = identities.get(key) ?? [];
901
+ entries.push(symbol.identity);
902
+ identities.set(key, entries);
903
+ }
904
+ const identity = (file, owner) => {
905
+ const candidates = owner ? identities.get(`${file}\0${owner.toLowerCase()}`) : undefined;
906
+ return candidates?.length === 1 ? candidates[0] : null;
907
+ };
908
+ db.run("SAVEPOINT apex_consumer_rebind");
909
+ try {
910
+ db.run("DELETE FROM apex_class_uses");
911
+ const insert = db.prepare("INSERT INTO apex_class_uses(filePath,startByte,endByte,kind,raw,status,targetFile,targetOwner,targetIdentity,ownerIdentity,isTest) VALUES (?,?,?,?,?,?,?,?,?,?,?)");
912
+ try {
913
+ for (const file of facts) {
914
+ for (const resolved of resolveApexClassUses(catalog, file)) {
915
+ const targetIdentity = resolved.target
916
+ ? identity(resolved.target.file, resolved.target.declaration.ownerPath)
917
+ : null;
918
+ const status = stored.length !== facts.length || (resolved.status === "resolved" && !targetIdentity)
919
+ ? "blocked"
920
+ : resolved.status;
921
+ insert.run(file.file, resolved.site.start.byte, resolved.site.end.byte, resolved.site.kind, JSON.stringify(resolved.site), status, resolved.target?.file ?? null, resolved.target?.declaration.ownerPath ?? null, status === "resolved" ? targetIdentity : null, identity(file.file, resolved.site.lexicalOwner), file.isTest === null ? null : file.isTest ? 1 : 0);
922
+ }
923
+ }
924
+ }
925
+ finally {
926
+ insert.finalize();
927
+ }
928
+ setMeta(db, "apexConsumerBoundRevision", getMeta(db, "apexConsumerFactsRevision") ?? "");
929
+ setMeta(db, "apexConsumerBindingVersion", APEX_CONSUMER_BINDING_VERSION);
930
+ setMeta(db, "apexConsumerMaterializationDigest", apexConsumerMaterializationDigest(db));
931
+ db.run("RELEASE apex_consumer_rebind");
932
+ }
933
+ catch (error) {
934
+ db.run("ROLLBACK TO apex_consumer_rebind");
935
+ db.run("RELEASE apex_consumer_rebind");
936
+ throw error;
937
+ }
938
+ }
939
+ function missingApexConsumerDescriptorFiles(db) {
940
+ const files = db
941
+ .query("SELECT filePath FROM index_state WHERE lower(filePath) LIKE '%.cls' OR lower(filePath) LIKE '%.trigger'")
942
+ .all()
943
+ .map((row) => row.filePath);
944
+ if (!files.length)
945
+ return [];
946
+ const hasTable = db
947
+ .query("SELECT COUNT(*) count FROM sqlite_master WHERE type='table' AND name='apex_consumer_files'")
948
+ .get()?.count === 1;
949
+ if (!hasTable)
950
+ return files;
951
+ const valid = new Set(db
952
+ .query("SELECT * FROM apex_consumer_files")
953
+ .all()
954
+ .filter((row) => {
955
+ const facts = readApexConsumerFacts(row);
956
+ if (!facts)
957
+ return false;
958
+ if (facts.parseState !== "complete")
959
+ return true;
960
+ if (facts.heritage === undefined &&
961
+ facts.unsupportedUses.some((use) => use.kind === "inheritance"))
962
+ return false;
963
+ const interfaces = facts.declarations.filter((d) => d.kind === "interface");
964
+ if (!interfaces.length)
965
+ return true;
966
+ const symbols = db
967
+ .query("SELECT * FROM symbols WHERE filePath=? AND kind='interface' AND identity IS NOT NULL")
968
+ .all(row.filePath);
969
+ const cache = new Map([[row.filePath, facts]]);
970
+ return interfaces.every((declaration) => declaration.declarationRange &&
971
+ symbols.some((symbol) => apexConsumerSymbolOwner(db, symbol, cache) === declaration.ownerPath));
972
+ })
973
+ .map((row) => row.filePath));
974
+ return files.filter((file) => !valid.has(file));
975
+ }
976
+ const apexConsumerValidationMemo = new Map();
977
+ const apexConsumerFastHits = new WeakMap();
978
+ const apexConsumerValidationMemoStats = new WeakMap();
979
+ const APEX_CONSUMER_MEMO_MAX_ENTRIES = 4;
980
+ const APEX_CONSUMER_MEMO_MAX_BYTES = 8 * 1024 * 1024;
981
+ /** Diagnostic counters only; never expose retained source or descriptors. */
982
+ export function getApexConsumerValidationMemoStats(db) {
983
+ return {
984
+ ...(apexConsumerValidationMemoStats.get(db) ?? { hits: 0, misses: 0 }),
985
+ retainedBytes: apexConsumerValidationMemo.get(db)?.retainedBytes ?? 0,
986
+ fastHits: apexConsumerFastHits.get(db) ?? 0,
987
+ };
988
+ }
989
+ /** Normal SQLite commit/snapshot semantics, not protection against malicious counter or file manipulation. */
990
+ function apexConsumerSnapshotStamp(db, transaction) {
991
+ const identity = db.readSnapshotIdentity();
992
+ if (identity.replaced) {
993
+ apexConsumerValidationMemo.delete(db);
994
+ throw new Error("Graph database file was replaced; reopen the engine before querying.");
995
+ }
996
+ if (identity.transaction !== transaction || identity.fileStamp === null)
997
+ return null;
998
+ try {
999
+ // This read lazily opens TEMP on a fresh connection. Normalize it before
1000
+ // sampling attachments so our own inspection does not invalidate the proof.
1001
+ const tempSchemaVersion = db
1002
+ .query("PRAGMA temp.schema_version")
1003
+ .get()?.schema_version;
1004
+ const attachments = db.query("PRAGMA database_list").all();
1005
+ if (attachments.some((row) => row.name !== "main" && row.name !== "temp"))
1006
+ return null;
1007
+ if (db.query("PRAGMA read_uncommitted").get()
1008
+ ?.read_uncommitted !== 0)
1009
+ return null;
1010
+ const values = [
1011
+ db.query("PRAGMA main.data_version").get()?.data_version,
1012
+ db.query("SELECT total_changes() value").get()?.value,
1013
+ db.query("PRAGMA main.schema_version").get()?.schema_version,
1014
+ tempSchemaVersion,
1015
+ ];
1016
+ if (!values.every((value) => Number.isSafeInteger(value) && value >= 0))
1017
+ return null;
1018
+ const schema = db
1019
+ .query("SELECT 'main' origin,name,sql FROM main.sqlite_master UNION ALL SELECT 'temp' origin,name,sql FROM sqlite_temp_master ORDER BY origin COLLATE BINARY,name COLLATE BINARY")
1020
+ .all();
1021
+ return crypto
1022
+ .createHash("sha256")
1023
+ .update(JSON.stringify([identity.fileStamp, attachments, values, schema]))
1024
+ .digest("hex");
1025
+ }
1026
+ catch {
1027
+ return null;
1028
+ }
1029
+ }
1030
+ function releaseApexConsumerRead(db, eligible, completed, proof) {
1031
+ try {
1032
+ db.run("RELEASE class_consumer_read");
1033
+ const afterStamp = eligible ? apexConsumerSnapshotStamp(db, false) : null;
1034
+ const cached = apexConsumerValidationMemo.get(db);
1035
+ if (cached) {
1036
+ if (completed && proof && afterStamp === proof.stamp && cached.key === proof.validationDigest)
1037
+ cached.fastProof = proof;
1038
+ else
1039
+ delete cached.fastProof;
1040
+ }
1041
+ }
1042
+ catch (error) {
1043
+ const cached = apexConsumerValidationMemo.get(db);
1044
+ if (cached)
1045
+ delete cached.fastProof;
1046
+ throw error;
1047
+ }
1048
+ }
1049
+ function validatedApexConsumerSummary(db, stored, key) {
1050
+ const stats = apexConsumerValidationMemoStats.get(db) ?? { hits: 0, misses: 0 };
1051
+ apexConsumerValidationMemoStats.set(db, stats);
1052
+ const cached = apexConsumerValidationMemo.get(db);
1053
+ if (cached?.key === key) {
1054
+ stats.hits++;
1055
+ apexConsumerValidationMemo.delete(db);
1056
+ apexConsumerValidationMemo.set(db, cached);
1057
+ return cached.summary;
1058
+ }
1059
+ stats.misses++;
1060
+ apexConsumerValidationMemo.delete(db);
1061
+ const valid = stored
1062
+ .map(readApexConsumerFacts)
1063
+ .filter((facts) => facts !== null);
1064
+ const summary = {
1065
+ validFiles: valid.length,
1066
+ invalidDescriptorFiles: stored.length - valid.length,
1067
+ partialFiles: valid.filter((facts) => facts.parseState !== "complete").length,
1068
+ expectedSites: valid.reduce((count, facts) => count + facts.sites.length, 0),
1069
+ unsupportedUses: valid.reduce((count, facts) => count + facts.unsupportedUses.length, 0),
1070
+ catalog: createApexConsumerCatalog(valid),
1071
+ };
1072
+ // Account for retained declaration strings/structure, not the much larger raw sites.
1073
+ // Byte and entry caps bound retention; this is not a precise V8 heap measurement.
1074
+ const retainedBytes = Buffer.byteLength(JSON.stringify([Array.from(summary.catalog.classes), Array.from(summary.catalog.members)])) + 256;
1075
+ if (summary.invalidDescriptorFiles === 0 && retainedBytes <= APEX_CONSUMER_MEMO_MAX_BYTES) {
1076
+ while (apexConsumerValidationMemo.size >= APEX_CONSUMER_MEMO_MAX_ENTRIES) {
1077
+ const oldest = apexConsumerValidationMemo.keys().next().value;
1078
+ if (!oldest)
1079
+ break;
1080
+ apexConsumerValidationMemo.delete(oldest);
1081
+ }
1082
+ apexConsumerValidationMemo.set(db, { key, summary, retainedBytes });
1083
+ }
1084
+ return summary;
1085
+ }
1086
+ /** @internal Snapshot reader; callers must establish repository freshness before entry. */
1087
+ export function readClassConsumerPage(db, repoPath, request) {
1088
+ if (!request.symbol ||
1089
+ !Number.isSafeInteger(request.limit) ||
1090
+ request.limit < 1 ||
1091
+ request.limit > 1000 ||
1092
+ !["all", "test", "production"].includes(request.testScope))
1093
+ throw new Error("Invalid class-consumer query bounds");
1094
+ const filterPath = request.path?.replace(/\/$/, "");
1095
+ if (filterPath &&
1096
+ (path.posix.normalize(filterPath) !== filterPath ||
1097
+ filterPath.startsWith("/") ||
1098
+ filterPath.split("/").includes("..") ||
1099
+ filterPath.includes("\\")))
1100
+ throw new Error("Class-consumer path must be canonical and repository-relative");
1101
+ const after = request.after;
1102
+ if (after &&
1103
+ (!Number.isSafeInteger(after.startByte) ||
1104
+ !Number.isSafeInteger(after.endByte) ||
1105
+ after.startByte < 0 ||
1106
+ after.endByte <= after.startByte ||
1107
+ !apexConsumerKinds.has(after.kind) ||
1108
+ path.posix.normalize(after.file) !== after.file ||
1109
+ after.file.startsWith("/") ||
1110
+ after.file.split("/").includes("..")))
1111
+ throw new Error("Invalid class-consumer page key");
1112
+ const beforeStamp = apexConsumerSnapshotStamp(db, false);
1113
+ if (!beforeStamp) {
1114
+ const cached = apexConsumerValidationMemo.get(db);
1115
+ if (cached)
1116
+ delete cached.fastProof;
1117
+ }
1118
+ let publishProof;
1119
+ let pinnedStamp = null;
1120
+ let completed = false;
1121
+ db.run("SAVEPOINT class_consumer_read");
1122
+ try {
1123
+ // SAVEPOINT alone is deferred: touch main to establish the read snapshot first.
1124
+ db.query("SELECT name FROM main.sqlite_master LIMIT 1").get();
1125
+ pinnedStamp = beforeStamp ? apexConsumerSnapshotStamp(db, true) : null;
1126
+ const memo = apexConsumerValidationMemo.get(db);
1127
+ const fastProof = pinnedStamp && pinnedStamp === beforeStamp && memo?.fastProof?.stamp === pinnedStamp
1128
+ ? memo.fastProof
1129
+ : undefined;
1130
+ if (fastProof) {
1131
+ apexConsumerFastHits.set(db, (apexConsumerFastHits.get(db) ?? 0) + 1);
1132
+ const stats = apexConsumerValidationMemoStats.get(db);
1133
+ if (stats)
1134
+ stats.hits++;
1135
+ }
1136
+ const indexCoverage = indexCoverageSummary(coverageForDatabase(db));
1137
+ const freshness = buildFreshnessEnvelope(repoPath, db, new Date().toISOString());
1138
+ const tablesPresent = (db
1139
+ .query("SELECT COUNT(*) count FROM sqlite_master WHERE type='table' AND name IN ('apex_consumer_files','apex_class_uses')")
1140
+ .get()?.count ?? 0) === 2;
1141
+ const stored = tablesPresent && !fastProof
1142
+ ? db
1143
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath")
1144
+ .all()
1145
+ : [];
1146
+ const missingDescriptorFiles = db
1147
+ .query(tablesPresent
1148
+ ? "SELECT COUNT(*) count FROM index_state i LEFT JOIN apex_consumer_files f ON f.filePath=i.filePath WHERE (lower(i.filePath) LIKE '%.cls' OR lower(i.filePath) LIKE '%.trigger') AND f.filePath IS NULL"
1149
+ : "SELECT COUNT(*) count FROM index_state WHERE lower(filePath) LIKE '%.cls' OR lower(filePath) LIKE '%.trigger'")
1150
+ .get()?.count ?? 0;
1151
+ const allClassRows = db
1152
+ .query("SELECT * FROM symbols WHERE kind IN ('class','interface') AND identity IS NOT NULL")
1153
+ .all()
1154
+ .filter((row) => /\.(?:cls|trigger)$/i.test(row.filePath));
1155
+ const typeFacts = new Map();
1156
+ if (tablesPresent)
1157
+ for (const row of allClassRows)
1158
+ if (row.kind === "interface")
1159
+ apexConsumerSymbolOwner(db, row, typeFacts);
1160
+ const typeOwner = (row) => tablesPresent
1161
+ ? apexConsumerSymbolOwner(db, row, typeFacts)
1162
+ : row.kind === "class"
1163
+ ? (parseApexDeclaration(row.apexDeclaration)?.owner ?? null)
1164
+ : null;
1165
+ const classRows = allClassRows.filter((row) => {
1166
+ if (row.name.toLowerCase() !== request.symbol.split(".").at(-1)?.toLowerCase())
1167
+ return false;
1168
+ if (tablesPresent && !typeFacts.has(row.filePath)) {
1169
+ const descriptor = db
1170
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath WHERE f.filePath=?")
1171
+ .get(row.filePath);
1172
+ typeFacts.set(row.filePath, descriptor ? readApexConsumerFacts(descriptor) : null);
1173
+ }
1174
+ return (typeOwner(row)?.toLowerCase() === request.symbol.toLowerCase() ||
1175
+ row.name.toLowerCase() === request.symbol.toLowerCase());
1176
+ });
1177
+ const selector = request.selector ?? {};
1178
+ const candidates = classRows.filter((row) => (!selector.file || row.filePath === selector.file) &&
1179
+ (!selector.kind || row.kind === selector.kind) &&
1180
+ (!selector.identity || symbolIdentityMatches(row.identity ?? "", selector.identity)));
1181
+ if (candidates.length === 0 &&
1182
+ tablesPresent &&
1183
+ !selector.identity &&
1184
+ (!selector.kind || selector.kind === "interface")) {
1185
+ const descriptorRows = stored.length
1186
+ ? stored
1187
+ : db
1188
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath")
1189
+ .all();
1190
+ const retained = descriptorRows
1191
+ .map(readApexConsumerFacts)
1192
+ .filter((f) => f !== null);
1193
+ const competitors = retained.flatMap((f) => f.declarations
1194
+ .filter((d) => ["class", "interface", "enum"].includes(d.kind) &&
1195
+ (d.name.toLowerCase() === request.symbol.toLowerCase() ||
1196
+ d.ownerPath.toLowerCase() === request.symbol.toLowerCase()))
1197
+ .map((d) => ({ file: f.file, state: f.parseState, declaration: d })));
1198
+ if (competitors.length === 1 &&
1199
+ competitors[0].state === "complete" &&
1200
+ competitors[0].declaration.kind === "interface" &&
1201
+ (!selector.file || selector.file === competitors[0].file))
1202
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_TARGET_CAPABILITY_MISSING", "An indexed interface declaration exists but its typed symbol capability is missing; repair the indexed scope.");
1203
+ }
1204
+ if (candidates.length !== 1) {
1205
+ if (candidates.length === 0)
1206
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_TARGET_NOT_FOUND", "No indexed Apex class matches the requested target and selectors.");
1207
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_TARGET_AMBIGUOUS", "Several indexed Apex classes match; select an exact identity or file.", candidates.slice(0, 100).map((row, index) => ({
1208
+ identity: row.identity,
1209
+ symbol: row.name,
1210
+ file: row.filePath,
1211
+ kind: row.kind,
1212
+ line: row.startLine,
1213
+ rank: index + 1,
1214
+ })));
1215
+ }
1216
+ const selected = candidates[0];
1217
+ const targetIdentity = selected.identity;
1218
+ const targetOwner = typeOwner(selected);
1219
+ if (!targetOwner && selected.kind === "interface")
1220
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_TARGET_CAPABILITY_MISSING", "The indexed interface lacks validated declaration-to-symbol provenance; repair the indexed scope.");
1221
+ if (!targetOwner)
1222
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_TARGET_NOT_FOUND", "No validated Apex type declaration matches the target.");
1223
+ const targetKind = selected.kind;
1224
+ const digest = (value) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
1225
+ const queryDigest = digest({
1226
+ version: 1,
1227
+ repository: path.resolve(repoPath),
1228
+ targetIdentity,
1229
+ targetKind,
1230
+ bindingVersion: APEX_CONSUMER_BINDING_VERSION,
1231
+ testScope: request.testScope,
1232
+ path: filterPath ?? null,
1233
+ });
1234
+ const factsRevision = getMeta(db, "apexConsumerFactsRevision");
1235
+ const boundRevision = getMeta(db, "apexConsumerBoundRevision");
1236
+ const bindingVersion = getMeta(db, "apexConsumerBindingVersion");
1237
+ const descriptorDigest = fastProof?.descriptorDigest ??
1238
+ digest(stored
1239
+ .map((row) => [
1240
+ row.filePath,
1241
+ row.version,
1242
+ row.sourceHash,
1243
+ row.factsHash,
1244
+ digest(row.facts),
1245
+ row.parseState,
1246
+ row.isTest,
1247
+ ])
1248
+ .sort((a, b) => compareBytes(String(a[0]), String(b[0]))));
1249
+ const materializedSites = tablesPresent
1250
+ ? (db.query("SELECT COUNT(*) count FROM apex_class_uses").get()
1251
+ ?.count ?? 0)
1252
+ : 0;
1253
+ const materializedDigest = fastProof
1254
+ ? fastProof.materializedDigest
1255
+ : tablesPresent
1256
+ ? apexConsumerMaterializationDigest(db)
1257
+ : null;
1258
+ // Unknown/changed native snapshots retain the full actual-byte scan, never revision-only trust.
1259
+ const inventory = fastProof
1260
+ ? []
1261
+ : db
1262
+ .query("SELECT filePath FROM index_state WHERE lower(filePath) LIKE '%.cls' OR lower(filePath) LIKE '%.trigger' ORDER BY filePath COLLATE BINARY")
1263
+ .all();
1264
+ const schema = fastProof
1265
+ ? []
1266
+ : db
1267
+ .query("SELECT 'main' origin,name,sql FROM sqlite_master UNION ALL SELECT 'temp' origin,name,sql FROM sqlite_temp_master ORDER BY origin COLLATE BINARY,name COLLATE BINARY")
1268
+ .all();
1269
+ const validationDigest = fastProof?.validationDigest ??
1270
+ digest({
1271
+ version: 1,
1272
+ descriptorDigest,
1273
+ inventory,
1274
+ schema,
1275
+ tablesPresent,
1276
+ missingDescriptorFiles,
1277
+ materializedSites,
1278
+ materializedDigest,
1279
+ factsRevision,
1280
+ boundRevision,
1281
+ bindingVersion,
1282
+ expectedMaterialization: getMeta(db, "apexConsumerMaterializationDigest"),
1283
+ });
1284
+ const validation = fastProof && memo ? memo.summary : validatedApexConsumerSummary(db, stored, validationDigest);
1285
+ const { expectedSites, invalidDescriptorFiles, partialFiles, unsupportedUses, catalog } = validation;
1286
+ // Fresh symbol proof is intentionally outside both validation caches. A removed outer
1287
+ // interface must not turn retained nested-class rows into a certified zero result.
1288
+ const missingInterfaceBindings = new Set();
1289
+ const interfaceSymbolSpans = new Set(allClassRows
1290
+ .filter((row) => row.kind === "interface")
1291
+ .map((row) => JSON.stringify([
1292
+ row.filePath,
1293
+ row.name,
1294
+ row.startLine,
1295
+ row.startCol,
1296
+ row.endLine,
1297
+ row.endCol,
1298
+ ])));
1299
+ for (const entries of catalog.classes.values())
1300
+ for (const entry of entries) {
1301
+ if (entry.declaration.kind !== "interface" || entry.parseState !== "complete")
1302
+ continue;
1303
+ const range = entry.declaration.declarationRange;
1304
+ if (!range ||
1305
+ !interfaceSymbolSpans.has(JSON.stringify([
1306
+ entry.file,
1307
+ entry.declaration.name,
1308
+ range.start.line,
1309
+ range.start.column - 1,
1310
+ range.end.line,
1311
+ range.end.column - 1,
1312
+ ])))
1313
+ missingInterfaceBindings.add(entry.file);
1314
+ }
1315
+ const snapshotDigest = digest({
1316
+ version: 1,
1317
+ queryDigest,
1318
+ validationDigest,
1319
+ classBindings: allClassRows
1320
+ .map((row) => [
1321
+ row.filePath,
1322
+ row.name,
1323
+ row.kind,
1324
+ row.identity,
1325
+ row.apexDeclaration,
1326
+ row.startLine,
1327
+ row.startCol,
1328
+ row.endLine,
1329
+ row.endCol,
1330
+ ])
1331
+ .sort((a, b) => compareBytes(JSON.stringify(a), JSON.stringify(b))),
1332
+ factsRevision,
1333
+ boundRevision,
1334
+ bindingVersion,
1335
+ descriptorDigest,
1336
+ materializedSites,
1337
+ materializedDigest,
1338
+ indexCoverage,
1339
+ head: freshness.currentHead,
1340
+ indexedHead: freshness.indexedHead,
1341
+ workingTree: freshness.workingTree,
1342
+ });
1343
+ if (request.expectedQueryDigest && request.expectedQueryDigest !== queryDigest)
1344
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_QUERY_MISMATCH", "The continuation does not describe this class-consumer query.");
1345
+ if (request.expectedSnapshotDigest && request.expectedSnapshotDigest !== snapshotDigest)
1346
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_STALE_CURSOR", "The indexed graph or coverage changed; restart class-consumer paging.");
1347
+ const unresolvedSites = tablesPresent
1348
+ ? (db
1349
+ .query("SELECT COUNT(*) count FROM apex_class_uses WHERE status<>'resolved'")
1350
+ .get()?.count ?? 0)
1351
+ : 0;
1352
+ const bound = Boolean(tablesPresent &&
1353
+ bindingVersion === APEX_CONSUMER_BINDING_VERSION &&
1354
+ factsRevision &&
1355
+ boundRevision === factsRevision &&
1356
+ materializedDigest === getMeta(db, "apexConsumerMaterializationDigest") &&
1357
+ materializedSites === expectedSites &&
1358
+ invalidDescriptorFiles === 0 &&
1359
+ missingDescriptorFiles === 0 &&
1360
+ missingInterfaceBindings.size === 0);
1361
+ const where = [
1362
+ "u.status='resolved'",
1363
+ "u.targetIdentity IS NOT NULL",
1364
+ "u.targetFile=?",
1365
+ "(lower(u.targetOwner)=lower(?) OR substr(lower(u.targetOwner),1,length(?)+1)=lower(?)||'.')",
1366
+ "u.filePath<>?",
1367
+ "u.isTest IS NOT NULL",
1368
+ ];
1369
+ const params = [
1370
+ selected.filePath,
1371
+ targetOwner,
1372
+ targetOwner,
1373
+ targetOwner,
1374
+ selected.filePath,
1375
+ ];
1376
+ if (!bound)
1377
+ where.push("0");
1378
+ if (request.testScope !== "all") {
1379
+ where.push("u.isTest=?");
1380
+ params.push(request.testScope === "test" ? 1 : 0);
1381
+ }
1382
+ if (filterPath) {
1383
+ where.push("(u.filePath=? OR substr(u.filePath,1,length(?)+1)=?||'/')");
1384
+ params.push(filterPath, filterPath, filterPath);
1385
+ }
1386
+ const from = "FROM apex_class_uses u JOIN index_state i ON i.filePath=u.filePath JOIN apex_consumer_files f ON f.filePath=u.filePath";
1387
+ const predicate = where.join(" AND ");
1388
+ const totals = tablesPresent
1389
+ ? db
1390
+ .query(`SELECT COUNT(*) count,COUNT(DISTINCT u.filePath) files ${from} WHERE ${predicate}`)
1391
+ .get(...params)
1392
+ : undefined;
1393
+ const targetInternalSites = tablesPresent
1394
+ ? (db
1395
+ .query("SELECT COUNT(*) count FROM apex_class_uses WHERE status='resolved' AND filePath=? AND targetFile=? AND (lower(targetOwner)=lower(?) OR substr(lower(targetOwner),1,length(?)+1)=lower(?)||'.')")
1396
+ .get(selected.filePath, selected.filePath, targetOwner, targetOwner, targetOwner)
1397
+ ?.count ?? 0)
1398
+ : 0;
1399
+ const pageParams = [...params];
1400
+ let afterSql = "";
1401
+ if (after) {
1402
+ afterSql =
1403
+ " AND (u.filePath COLLATE BINARY,u.startByte,u.endByte,u.kind COLLATE BINARY)>(? COLLATE BINARY,?,?,? COLLATE BINARY)";
1404
+ pageParams.push(after.file, after.startByte, after.endByte, after.kind);
1405
+ }
1406
+ pageParams.push(request.limit + 1);
1407
+ const rows = tablesPresent
1408
+ ? db
1409
+ .query(`SELECT u.* ${from} WHERE ${predicate}${afterSql} ORDER BY u.filePath COLLATE BINARY,u.startByte,u.endByte,u.kind COLLATE BINARY LIMIT ?`)
1410
+ .all(...pageParams)
1411
+ : [];
1412
+ const storedByFile = new Map(stored.map((row) => [row.filePath, row]));
1413
+ const pageFacts = new Map();
1414
+ const proofByFile = new Map();
1415
+ let invalidRows = false;
1416
+ const results = [];
1417
+ for (const row of rows.slice(0, request.limit)) {
1418
+ let site;
1419
+ try {
1420
+ site = JSON.parse(row.raw);
1421
+ }
1422
+ catch {
1423
+ invalidRows = true;
1424
+ continue;
1425
+ }
1426
+ if (!validConsumerSite(site) ||
1427
+ site.blockedReason ||
1428
+ site.start.byte !== row.startByte ||
1429
+ site.end.byte !== row.endByte ||
1430
+ site.kind !== row.kind) {
1431
+ invalidRows = true;
1432
+ continue;
1433
+ }
1434
+ if (!pageFacts.has(row.filePath)) {
1435
+ const descriptor = storedByFile.get(row.filePath) ??
1436
+ (fastProof
1437
+ ? db
1438
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath WHERE f.filePath=?")
1439
+ .get(row.filePath)
1440
+ : null);
1441
+ pageFacts.set(row.filePath, descriptor ? readApexConsumerFacts(descriptor) : null);
1442
+ }
1443
+ const facts = pageFacts.get(row.filePath);
1444
+ if (!facts || facts.isTest !== (row.isTest === 1)) {
1445
+ invalidRows = true;
1446
+ continue;
1447
+ }
1448
+ let proofs = proofByFile.get(row.filePath);
1449
+ if (!proofs) {
1450
+ proofs = resolveApexClassUses(catalog, facts);
1451
+ proofByFile.set(row.filePath, proofs);
1452
+ }
1453
+ const proof = proofs.find((entry) => entry.status === "resolved" && JSON.stringify(entry.site) === row.raw);
1454
+ if (!proof?.target ||
1455
+ proof.target.file !== selected.filePath ||
1456
+ !(proof.target.declaration.ownerPath.toLowerCase() === targetOwner.toLowerCase() ||
1457
+ proof.target.declaration.ownerPath
1458
+ .toLowerCase()
1459
+ .startsWith(`${targetOwner.toLowerCase()}.`))) {
1460
+ invalidRows = true;
1461
+ continue;
1462
+ }
1463
+ const owners = allClassRows.filter((candidate) => candidate.filePath === row.filePath &&
1464
+ typeOwner(candidate)?.toLowerCase() === site.lexicalOwner?.toLowerCase());
1465
+ const ownerIdentity = owners.length === 1 ? (owners[0].identity ?? null) : null;
1466
+ results.push({
1467
+ key: { file: row.filePath, startByte: row.startByte, endByte: row.endByte, kind: row.kind },
1468
+ file: row.filePath,
1469
+ start: { line: site.start.line, column: site.start.column, byte: site.start.byte },
1470
+ end: { line: site.end.line, column: site.end.column, byte: site.end.byte },
1471
+ kind: site.kind,
1472
+ qualifiedSpelling: site.qualifiedSpelling,
1473
+ ownerIdentity,
1474
+ isTest: row.isTest === 1,
1475
+ grounding: "syntax-resolved",
1476
+ });
1477
+ }
1478
+ if (invalidRows)
1479
+ results.length = 0;
1480
+ const files = [...new Set(results.map((site) => site.file))].map((file) => ({
1481
+ file,
1482
+ isTest: results.find((site) => site.file === file)?.isTest ?? false,
1483
+ siteCount: db
1484
+ .query(`SELECT COUNT(*) count ${from} WHERE ${predicate} AND u.filePath=?`)
1485
+ .get(...params, file)?.count ?? 0,
1486
+ returnedSiteCount: results.filter((site) => site.file === file).length,
1487
+ }));
1488
+ const endStamp = pinnedStamp ? apexConsumerSnapshotStamp(db, true) : null;
1489
+ if (pinnedStamp && endStamp !== pinnedStamp) {
1490
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_STALE_CURSOR", "The graph changed while reading its snapshot; retry the query.");
1491
+ }
1492
+ if (bound && !invalidRows && pinnedStamp && beforeStamp === pinnedStamp)
1493
+ publishProof = { stamp: pinnedStamp, descriptorDigest, materializedDigest, validationDigest };
1494
+ completed = true;
1495
+ return {
1496
+ targetIdentity,
1497
+ targetKind,
1498
+ queryDigest,
1499
+ snapshotDigest,
1500
+ count: invalidRows ? 0 : (totals?.count ?? 0),
1501
+ consumerFileCount: invalidRows ? 0 : (totals?.files ?? 0),
1502
+ returnedCount: results.length,
1503
+ results,
1504
+ files,
1505
+ hasMore: !invalidRows && rows.length > request.limit,
1506
+ analysis: {
1507
+ version: 1,
1508
+ grounding: "syntax-resolved",
1509
+ partial: !bound ||
1510
+ missingDescriptorFiles > 0 ||
1511
+ partialFiles > 0 ||
1512
+ invalidRows ||
1513
+ unresolvedSites > 0 ||
1514
+ unsupportedUses > 0,
1515
+ inspectedFiles: validation.validFiles,
1516
+ missingDescriptorFiles: missingDescriptorFiles + missingInterfaceBindings.size,
1517
+ invalidDescriptorFiles: invalidDescriptorFiles + (invalidRows ? 1 : 0),
1518
+ unresolvedSites,
1519
+ unsupportedUses,
1520
+ exclusions: { targetInternalSites },
1521
+ limitations: [
1522
+ "Only the nine supported explicit Apex syntax roles are resolved; this is not runtime reachability or complete class-use coverage.",
1523
+ "Descriptor, unresolved-site and unsupported-use counters cover the inspected indexed Apex inventory, not just this target.",
1524
+ "Inherited or ambiguous value/type scopes can remain unresolved; target self-file sites are excluded.",
1525
+ "Query results cover this repository's indexed graph scope only, not federated repositories or metadata.",
1526
+ ],
1527
+ },
1528
+ indexCoverage,
1529
+ freshness,
1530
+ };
1531
+ }
1532
+ finally {
1533
+ releaseApexConsumerRead(db, beforeStamp !== null, completed, publishProof);
1534
+ }
683
1535
  }
684
1536
  function resolveSymbolRows(db, repoPath, name, selector = {}) {
685
1537
  let rows = db.query("SELECT * FROM symbols WHERE name = ?").all(name);
@@ -1278,7 +2130,8 @@ export function resetSearchCacheStats() {
1278
2130
  }
1279
2131
  }
1280
2132
  /**
1281
- * Decode a repository's embedded symbol metadata once per index generation.
2133
+ * Decode a repository's symbol metadata once per index generation, including
2134
+ * structural-only symbols. Semantic coverage must not gate lexical retrieval.
1282
2135
  * Corrupt vectors remain represented so totals and FTS behavior stay truthful,
1283
2136
  * but exact vector scoring can skip them without reparsing every warm query.
1284
2137
  */
@@ -1294,12 +2147,14 @@ function getSearchMetadataSnapshot(repoPath, db) {
1294
2147
  const stmt = db.query(`
1295
2148
  SELECT s.id, s.identity, s.name, s.kind, s.filePath, s.startLine, s.endLine, se.embedding
1296
2149
  FROM symbols s
1297
- JOIN symbol_embeddings se ON s.id = se.symbolId
2150
+ LEFT JOIN symbol_embeddings se ON s.id = se.symbolId
1298
2151
  `);
1299
2152
  const raw = stmt.all();
1300
2153
  stmt.finalize();
1301
2154
  return raw.map((row) => {
1302
2155
  const { embedding, ...metadata } = row;
2156
+ if (embedding === null)
2157
+ return { ...metadata, vector: null };
1303
2158
  try {
1304
2159
  return { ...metadata, vector: bufferToFloat32(embedding) };
1305
2160
  }
@@ -2526,14 +3381,17 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
2526
3381
  }
2527
3382
  }
2528
3383
  else if (isApex) {
2529
- if (node.type === "class_declaration") {
3384
+ if (node.type === "class_declaration" || node.type === "interface_declaration") {
2530
3385
  const nameNode = node.childForFieldName("name") || node.children.find((c) => c.type === "identifier");
2531
3386
  if (nameNode) {
2532
3387
  const name = nameNode.text;
2533
3388
  symName = name;
2534
3389
  definitions.push({
3390
+ ...(node.type === "class_declaration"
3391
+ ? { apexDeclaration: JSON.stringify(apexDeclaration(node)) }
3392
+ : {}),
2535
3393
  name,
2536
- kind: "class",
3394
+ kind: node.type === "class_declaration" ? "class" : "interface",
2537
3395
  startLine: node.startPosition.row + 1,
2538
3396
  endLine: node.endPosition.row + 1,
2539
3397
  startCol: node.startPosition.column,
@@ -2598,6 +3456,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
2598
3456
  symName = name;
2599
3457
  isNewSymbol = true;
2600
3458
  definitions.push({
3459
+ apexDeclaration: JSON.stringify(apexDeclaration(node)),
2601
3460
  name,
2602
3461
  kind: "method",
2603
3462
  startLine: node.startPosition.row + 1,
@@ -2768,6 +3627,15 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
2768
3627
  }
2769
3628
  // --- Identify Dependencies (Imports) ---
2770
3629
  if (!isPython && !isApex) {
3630
+ // A source-bearing re-export is a real module dependency even though it
3631
+ // introduces no local binding. The AST source field covers named,
3632
+ // aliased, namespace and star exports without mistaking strings or
3633
+ // comments for wiring. Local `export { value }` has no source field.
3634
+ if (node.type === "export_statement") {
3635
+ const sourceNode = node.childForFieldName("source");
3636
+ if (sourceNode?.type === "string")
3637
+ fileDependencies.push(sourceNode.text.replace(/^['"`]|['"`]$/g, ""));
3638
+ }
2771
3639
  if (node.type === "import_statement" || node.type === "import_declaration") {
2772
3640
  const sourceNode = node.childForFieldName("source");
2773
3641
  if (sourceNode?.type === "string") {
@@ -2847,6 +3715,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
2847
3715
  if (nameNode) {
2848
3716
  const calleeName = nameNode.text;
2849
3717
  references.push({
3718
+ apexCall: node.childForFieldName("object") ? JSON.stringify(apexCall(node)) : undefined,
2850
3719
  callerSymbol: getEnclosingSymbol(),
2851
3720
  calleeSymbol: calleeName,
2852
3721
  line: node.startPosition.row + 1,
@@ -2860,6 +3729,7 @@ function extractSymbolsAndReferences(rootNode, isPython, isApex = false, filePat
2860
3729
  if (typeNode) {
2861
3730
  const calleeName = typeNode.text;
2862
3731
  references.push({
3732
+ apexCall: JSON.stringify(apexCall(node)),
2863
3733
  callerSymbol: getEnclosingSymbol(),
2864
3734
  calleeSymbol: calleeName,
2865
3735
  line: node.startPosition.row + 1,
@@ -3453,19 +4323,25 @@ const packageMapCache = new Map();
3453
4323
  function invalidateResolutionCaches(repoPath) {
3454
4324
  const resolved = path.resolve(repoPath);
3455
4325
  packageMapCache.delete(resolved);
4326
+ salesforceBindingTargetCache.delete(resolved);
3456
4327
  const prefix = `${resolved}\0`;
3457
4328
  for (const key of reExportCache.keys())
3458
4329
  if (key.startsWith(prefix))
3459
4330
  reExportCache.delete(key);
3460
4331
  }
4332
+ const explicitPackageInputs = new AsyncLocalStorage();
3461
4333
  function getPackageMap(repoPath) {
3462
4334
  const resolvedRepoPath = path.resolve(repoPath);
3463
4335
  const cached = packageMapCache.get(resolvedRepoPath);
3464
- if (cached?.generation === indexGeneration)
4336
+ const explicitInputs = explicitPackageInputs.getStore() ?? [];
4337
+ if (!explicitInputs.length && cached?.generation === indexGeneration)
3465
4338
  return cached.value;
3466
4339
  const map = new Map();
3467
4340
  let cacheable = true;
3468
- for (const rel of listSourceFiles(repoPath, (file) => path.posix.basename(file) === "package.json")) {
4341
+ for (const rel of new Set([
4342
+ ...explicitInputs,
4343
+ ...listSourceFiles(repoPath, (file) => path.posix.basename(file) === "package.json"),
4344
+ ])) {
3469
4345
  try {
3470
4346
  const manifest = readWholeSource(repoPath, rel);
3471
4347
  if (manifest.availability.state !== "available")
@@ -3485,7 +4361,7 @@ function getPackageMap(repoPath) {
3485
4361
  cacheable = false;
3486
4362
  }
3487
4363
  }
3488
- if (cacheable)
4364
+ if (cacheable && !explicitInputs.length)
3489
4365
  packageMapCache.set(resolvedRepoPath, {
3490
4366
  generation: indexGeneration,
3491
4367
  value: map,
@@ -3637,10 +4513,7 @@ function getLineAndColumnForIndex(content, index) {
3637
4513
  return { line, column };
3638
4514
  }
3639
4515
  function findClassFilePath(className, repoPath) {
3640
- return (walkRepoFiles(repoPath, {
3641
- accept: (file) => path.posix.basename(file) === `${className}.cls`,
3642
- limit: 1,
3643
- })[0] ?? null);
4516
+ return (listSourceFiles(repoPath, (file) => path.posix.basename(file) === `${className}.cls`)[0] ?? null);
3644
4517
  }
3645
4518
  /**
3646
4519
  * C19 only reports an Apex bridge when its class path is unambiguous. The
@@ -3773,6 +4646,9 @@ function salesforceMetadataFile(relativePath) {
3773
4646
  const root = "force-app/main/default/";
3774
4647
  if (!normalized.startsWith(root))
3775
4648
  return null;
4649
+ const webLink = /^force-app\/main\/default\/objects\/([A-Za-z]\w*)\/webLinks\/([A-Za-z]\w*)\.webLink-meta\.xml$/.exec(normalized);
4650
+ if (webLink)
4651
+ return { type: "weblink", object: webLink[1], name: webLink[2] };
3776
4652
  let match = /^force-app\/main\/default\/objects\/([A-Za-z]\w*)\/\1\.object-meta\.xml$/.exec(normalized);
3777
4653
  if (match)
3778
4654
  return { type: "object", object: match[1] };
@@ -3787,6 +4663,13 @@ function salesforceMetadataFile(relativePath) {
3787
4663
  match = /^force-app\/main\/default\/permissionSets\/([A-Za-z]\w*)\.permissionSet-meta\.xml$/.exec(normalized);
3788
4664
  if (match)
3789
4665
  return { type: "permissionSet", name: match[1] };
4666
+ match =
4667
+ /^force-app\/main\/default\/quickActions\/([A-Za-z]\w*(?:\.[A-Za-z]\w*)?)\.quickAction-meta\.xml$/.exec(normalized);
4668
+ if (match)
4669
+ return { type: "quickAction", name: match[1] };
4670
+ match = /^force-app\/main\/default\/flexipages\/([A-Za-z]\w*)\.flexipage-meta\.xml$/.exec(normalized);
4671
+ if (match)
4672
+ return { type: "flexipage", name: match[1] };
3790
4673
  match = /^force-app\/main\/default\/flows\/([A-Za-z]\w*)\.flow-meta\.xml$/.exec(normalized);
3791
4674
  if (match)
3792
4675
  return { type: "flow", name: match[1] };
@@ -3810,11 +4693,12 @@ function findUniqueInvocableApexMethod(className, methodName, repoPath) {
3810
4693
  const source = read.text;
3811
4694
  const matches = [];
3812
4695
  for (const match of source.matchAll(/@InvocableMethod\b[\s\S]{0,300}?\b(?:global|public)\s+static\s(?:\s|[^({;]*?(?<!\s))\s+([a-z]\w*)\s*\(/gi)) {
3813
- if (!isExecutablePosition(source, match.index ?? 0) || match[1] !== methodName)
4696
+ if (!isExecutablePosition(source, match.index ?? 0) ||
4697
+ (methodName !== undefined && match[1] !== methodName))
3814
4698
  continue;
3815
4699
  matches.push({
3816
4700
  file,
3817
- method: methodName,
4701
+ method: match[1],
3818
4702
  line: source.slice(0, match.index ?? 0).split("\n").length,
3819
4703
  evidence: match[0].trim(),
3820
4704
  });
@@ -4009,7 +4893,9 @@ export const SALESFORCE_METADATA_KINDS = [
4009
4893
  "salesforce_field",
4010
4894
  "salesforce_record_type",
4011
4895
  "salesforce_permission_set",
4896
+ "salesforce_quick_action",
4012
4897
  "salesforce_flow",
4898
+ "salesforce_weblink",
4013
4899
  "salesforce_workflow",
4014
4900
  "salesforce_approval",
4015
4901
  ];
@@ -4018,7 +4904,10 @@ const SALESFORCE_KIND_BY_TYPE = {
4018
4904
  field: "salesforce_field",
4019
4905
  recordType: "salesforce_record_type",
4020
4906
  permissionSet: "salesforce_permission_set",
4907
+ quickAction: "salesforce_quick_action",
4908
+ flexipage: "salesforce_flexipage",
4021
4909
  flow: "salesforce_flow",
4910
+ weblink: "salesforce_weblink",
4022
4911
  workflow: "salesforce_workflow",
4023
4912
  approval: "salesforce_approval",
4024
4913
  };
@@ -4058,7 +4947,9 @@ function describeSalesforceMetadata(metadata, content) {
4058
4947
  const label = first("label") ?? first("masterLabel");
4059
4948
  const name = metadata.type === "flow"
4060
4949
  ? metadata.name
4061
- : metadata.type === "permissionSet"
4950
+ : metadata.type === "permissionSet" ||
4951
+ metadata.type === "quickAction" ||
4952
+ metadata.type === "flexipage"
4062
4953
  ? metadata.name
4063
4954
  : metadata.type === "recordType"
4064
4955
  ? `${metadata.object}.${metadata.recordType}`
@@ -4085,6 +4976,20 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
4085
4976
  add(qualified, kind, evidence);
4086
4977
  };
4087
4978
  switch (metadata.type) {
4979
+ case "flexipage": {
4980
+ for (const entry of await resolvedSalesforceComponentBindings(content, "flexipage", repoPath))
4981
+ add(entry.to, entry.kind, entry.sourceEvidence);
4982
+ break;
4983
+ }
4984
+ case "quickAction": {
4985
+ const component = quickActionComponent(content);
4986
+ if (component) {
4987
+ const target = findUniqueSalesforceBindingTarget(component.name, repoPath, "aura");
4988
+ if (target)
4989
+ add(target, "quickaction_lightning_component", component.evidence);
4990
+ }
4991
+ break;
4992
+ }
4088
4993
  case "object":
4089
4994
  add(metadata.object, "salesforce_metadata_object", "Salesforce DX CustomObject file path");
4090
4995
  break;
@@ -4142,7 +5047,7 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
4142
5047
  if (!/<actionType>apex<\/actionType>/.test(action[1]))
4143
5048
  continue;
4144
5049
  for (const entry of staticXmlTagValues(action[1], "actionName")) {
4145
- const target = /^([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(entry.value);
5050
+ const target = /^([A-Za-z]\w*)(?:\.([A-Za-z]\w*))?$/.exec(entry.value);
4146
5051
  if (!target)
4147
5052
  continue;
4148
5053
  const invocable = findUniqueInvocableApexMethod(target[1], target[2], repoPath);
@@ -4159,16 +5064,14 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
4159
5064
  });
4160
5065
  }
4161
5066
  }
4162
- for (const entry of staticXmlTagValues(content, "extensionName")) {
4163
- const component = /^c:([A-Za-z]\w*)$/.exec(entry.value);
4164
- if (!component)
4165
- continue;
4166
- const target = findSalesforceLwcComponentFile(component[1], repoPath);
4167
- if (target)
4168
- add(target, "flow_screen_component", entry.evidence);
4169
- }
5067
+ for (const binding of await resolvedSalesforceComponentBindings(content, "flow", repoPath))
5068
+ add(binding.to, binding.kind, binding.sourceEvidence);
4170
5069
  break;
4171
5070
  }
5071
+ case "weblink":
5072
+ for (const binding of await resolvedSalesforceComponentBindings(content, "weblink", repoPath))
5073
+ add(binding.to, binding.kind, binding.sourceEvidence);
5074
+ break;
4172
5075
  case "workflow":
4173
5076
  add(metadata.object, "workflow_object", "Salesforce DX workflow object file path");
4174
5077
  for (const entry of staticXmlTagValues(content, "field"))
@@ -4224,21 +5127,255 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
4224
5127
  throw error;
4225
5128
  }
4226
5129
  }
5130
+ /** Mask non-markup content without interpreting entities or loading external resources. */
5131
+ function salesforceBindingMarkup(content) {
5132
+ return content.replace(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>/g, (value) => value.replace(/[^\n]/g, " "));
5133
+ }
5134
+ function quickActionComponent(content) {
5135
+ const markup = salesforceBindingMarkup(content);
5136
+ if (!/^\s*(?:<\?xml[^?]*\?>\s*)?<QuickAction(?:\s[^<>]*)?>/.test(markup))
5137
+ return null;
5138
+ const types = staticXmlTagValues(markup, "type");
5139
+ const components = staticXmlTagValues(markup, "lightningComponent");
5140
+ if (types.length !== 1 || types[0].value !== "LightningComponent" || components.length !== 1)
5141
+ return null;
5142
+ const name = /^(?:c:)?([A-Za-z]\w*)$/.exec(components[0].value)?.[1];
5143
+ return name && !name.includes("__") ? { name, evidence: components[0].evidence } : null;
5144
+ }
5145
+ const salesforceBindingTargetCache = new Map();
5146
+ /** One source-path catalog per graph generation, never a walk for each binding. */
5147
+ function salesforceBindingTargets(repoPath) {
5148
+ const key = path.resolve(repoPath);
5149
+ const coverageDigest = indexCoverageSummary(coverageForDatabase(extractionCoverage.getStore() ?? dbInstances.get(key))).digest;
5150
+ let cached = salesforceBindingTargetCache.get(key);
5151
+ if (!cached ||
5152
+ cached.generation !== indexGeneration ||
5153
+ cached.coverageDigest !== coverageDigest) {
5154
+ const targets = new Map();
5155
+ const files = listSourceFiles(repoPath, (file) => file.endsWith(".cls") ||
5156
+ file.endsWith(".cmp") ||
5157
+ file.endsWith(".js") ||
5158
+ file.endsWith(".app") ||
5159
+ file.endsWith(".page"));
5160
+ for (const file of files) {
5161
+ const name = file.endsWith(".cls")
5162
+ ? path.posix.basename(file, ".cls")
5163
+ : /(?:^|\/)aura\/([A-Za-z]\w*)\/\1\.cmp$/.exec(file)?.[1];
5164
+ if (!name)
5165
+ continue;
5166
+ const targetKey = `${file.endsWith(".cls") ? "apex" : "aura"}:${name}`;
5167
+ const files = targets.get(targetKey) ?? [];
5168
+ if (files.length < 2)
5169
+ files.push(file);
5170
+ targets.set(targetKey, files);
5171
+ }
5172
+ cached = {
5173
+ generation: indexGeneration,
5174
+ coverageDigest,
5175
+ targets,
5176
+ components: componentCatalog(files),
5177
+ };
5178
+ if (salesforceBindingTargetCache.size >= 8)
5179
+ salesforceBindingTargetCache.clear();
5180
+ salesforceBindingTargetCache.set(key, cached);
5181
+ }
5182
+ return cached;
5183
+ }
5184
+ function findUniqueSalesforceBindingTarget(name, repoPath, kind) {
5185
+ const matches = salesforceBindingTargets(repoPath).targets.get(`${kind}:${name}`) ?? [];
5186
+ return matches.length === 1 ? matches[0] : null;
5187
+ }
5188
+ /** Reuse the shipped XML grammar; HTML deliberately retains its own literal subset. */
5189
+ async function salesforceComponentBindings(content, format) {
5190
+ if (format === "lwc")
5191
+ return componentBindings(content, format);
5192
+ const language = await getLanguageForFile("component.xml");
5193
+ if (!language)
5194
+ return [];
5195
+ const parser = new Parser();
5196
+ try {
5197
+ parser.setLanguage(language);
5198
+ return componentBindings(content, format, (source) => {
5199
+ const tree = parser.parse(source);
5200
+ try {
5201
+ return !tree.rootNode.hasError();
5202
+ }
5203
+ finally {
5204
+ tree.delete();
5205
+ }
5206
+ });
5207
+ }
5208
+ finally {
5209
+ parser.delete();
5210
+ }
5211
+ }
5212
+ async function resolvedSalesforceComponentBindings(content, format, repoPath) {
5213
+ const bindings = await salesforceComponentBindings(content, format);
5214
+ if (!bindings.length)
5215
+ return [];
5216
+ const catalog = salesforceBindingTargets(repoPath).components;
5217
+ const seen = new Set();
5218
+ return bindings.flatMap((binding) => {
5219
+ const target = catalog.resolve(binding);
5220
+ if (!target)
5221
+ return [];
5222
+ const key = `${target.file}\0${binding.kind}\0${binding.evidence}`;
5223
+ if (seen.has(key))
5224
+ return [];
5225
+ seen.add(key);
5226
+ return [
5227
+ {
5228
+ to: target.file,
5229
+ kind: binding.kind === "aura_component" && target.kind === "lwc"
5230
+ ? "aura_lwc_component"
5231
+ : binding.kind,
5232
+ sourceEvidence: binding.evidence,
5233
+ },
5234
+ ];
5235
+ });
5236
+ }
5237
+ /** Literal design-time Apex data sources, not method execution or runtime dispatch. */
5238
+ function appBuilderApexDataSources(content, aura) {
5239
+ const markup = salesforceBindingMarkup(content);
5240
+ const root = aura ? "design:component" : "LightningComponentBundle";
5241
+ if (!new RegExp(String.raw `^\s*(?:<\?xml[^?]*\?>\s*)?<${root}(?:\s[^<>]*)?>`).test(markup))
5242
+ return [];
5243
+ const regions = aura
5244
+ ? [markup]
5245
+ : [...markup.matchAll(/<targetConfig\b[^<>]*>([\s\S]*?)<\/targetConfig>/g)].map((match) => match[1]);
5246
+ const results = [];
5247
+ const tag = aura ? "design:attribute" : "property";
5248
+ for (const region of regions) {
5249
+ for (const match of region.matchAll(new RegExp(String.raw `<${tag}\b(?:[^"'<>]|"[^"]*"|'[^']*')*\/?>`, "g"))) {
5250
+ const attributes = [
5251
+ ...match[0].matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g),
5252
+ ];
5253
+ const sources = attributes.filter((attribute) => attribute[1] === "datasource");
5254
+ if (sources.length !== 1)
5255
+ continue;
5256
+ const name = /^apex:\/\/([A-Za-z]\w*)$/.exec(sources[0][2] ?? sources[0][3])?.[1];
5257
+ if (name && !name.includes("__"))
5258
+ results.push({ name, evidence: sources[0][0] });
5259
+ }
5260
+ }
5261
+ return results;
5262
+ }
5263
+ /** Re-evaluate indexed bindings, including previously missing/ambiguous targets. */
5264
+ async function refreshSalesforceMetadataBindings(db, repoPath, changedFiles) {
5265
+ const classes = new Set(changedFiles.filter((file) => file.endsWith(".cls")).map((file) => path.basename(file, ".cls")));
5266
+ const components = new Set(changedFiles.filter((file) => file.endsWith(".cmp")).map((file) => path.basename(file, ".cmp")));
5267
+ const componentNames = new Set(changedFiles.flatMap((file) => {
5268
+ const name = /^force-app\/main\/default\/(?:aura|lwc)\/([A-Za-z]\w*)\/\1\.(?:cmp|js)$/.exec(file)?.[1];
5269
+ return name ? [name] : [];
5270
+ }));
5271
+ const componentTags = new Set([...componentNames].map((name) => name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)));
5272
+ const applicationNames = new Set(changedFiles.flatMap((file) => /^force-app\/main\/default\/aura\/([A-Za-z]\w*)\/\1\.app$/.exec(file)?.slice(1) ?? []));
5273
+ const pageNames = new Set(changedFiles.flatMap((file) => /^force-app\/main\/default\/pages\/([A-Za-z]\w*)\.page$/.exec(file)?.slice(1) ?? []));
5274
+ if (!classes.size &&
5275
+ !components.size &&
5276
+ !componentNames.size &&
5277
+ !applicationNames.size &&
5278
+ !pageNames.size)
5279
+ return [];
5280
+ // Target additions/deletions can precede the generation increment. Refresh
5281
+ // the catalog before reconsidering formerly missing or ambiguous bindings.
5282
+ salesforceBindingTargetCache.delete(path.resolve(repoPath));
5283
+ const candidates = db
5284
+ .query("SELECT filePath FROM index_state WHERE filePath LIKE 'force-app/main/default/quickActions/%.quickAction-meta.xml' OR filePath LIKE 'force-app/main/default/lwc/%.js-meta.xml' OR filePath LIKE 'force-app/main/default/aura/%.design' OR filePath LIKE 'force-app/main/default/aura/%.cmp' OR filePath LIKE 'force-app/main/default/aura/%.app' OR filePath LIKE 'force-app/main/default/lwc/%.html' OR filePath LIKE 'force-app/main/default/flexipages/%.flexipage-meta.xml' OR filePath LIKE 'force-app/main/default/flows/%.flow-meta.xml' OR filePath LIKE 'force-app/main/default/pages/%.page' OR filePath LIKE 'force-app/main/default/objects/%/webLinks/%.webLink-meta.xml' ORDER BY filePath")
5285
+ .all();
5286
+ const refreshed = [];
5287
+ for (const { filePath } of candidates) {
5288
+ if (/^force-app\/main\/default\/pages\/[A-Za-z]\w*\.page$/.test(filePath)) {
5289
+ if (!applicationNames.size)
5290
+ continue;
5291
+ const read = readWholeSource(repoPath, filePath);
5292
+ if (read.availability.state !== "available" ||
5293
+ !(await visualforceLightningBindings(read.text)).some((binding) => applicationNames.has(binding.name)))
5294
+ continue;
5295
+ await indexFile(path.join(repoPath, filePath), filePath, repoPath, db);
5296
+ recordIndexState(db, repoPath, filePath);
5297
+ refreshed.push(filePath);
5298
+ continue;
5299
+ }
5300
+ const metadata = salesforceMetadataFile(filePath);
5301
+ const auraSource = auraBundleFile(filePath);
5302
+ const lwcSource = lwcBundleFile(filePath);
5303
+ const format = metadata?.type === "flexipage" || metadata?.type === "flow" || metadata?.type === "weblink"
5304
+ ? metadata.type
5305
+ : auraSource && (auraSource.extension === "cmp" || auraSource.extension === "app")
5306
+ ? "aura"
5307
+ : lwcSource?.extension === "html"
5308
+ ? "lwc"
5309
+ : undefined;
5310
+ // Filter by changed target category before opening source. An Apex-only
5311
+ // edit must not read every component template in a large repository.
5312
+ if (format
5313
+ ? !(format === "weblink" ? pageNames : componentNames).size
5314
+ : metadata?.type === "quickAction"
5315
+ ? !components.size
5316
+ : !classes.size)
5317
+ continue;
5318
+ const read = readWholeSource(repoPath, filePath);
5319
+ if (read.availability.state !== "available")
5320
+ continue;
5321
+ if (format) {
5322
+ const bindings = await salesforceComponentBindings(read.text, format);
5323
+ if (!bindings.some((binding) => (binding.target === "page"
5324
+ ? pageNames
5325
+ : binding.target === "lwc-tag"
5326
+ ? componentTags
5327
+ : componentNames).has(binding.name)))
5328
+ continue;
5329
+ if (metadata?.type === "flexipage" ||
5330
+ metadata?.type === "flow" ||
5331
+ metadata?.type === "weblink")
5332
+ await indexSalesforceMetadataFile(read.text, filePath, repoPath, db, metadata);
5333
+ else if (auraSource)
5334
+ await indexAuraBundleFile(read.text, filePath, repoPath, db, auraSource);
5335
+ else if (lwcSource)
5336
+ await indexLwcBundleFile(read.text, filePath, repoPath, db, lwcSource);
5337
+ }
5338
+ else if (metadata?.type === "quickAction") {
5339
+ const target = quickActionComponent(read.text);
5340
+ if (!target || !components.has(target.name))
5341
+ continue;
5342
+ await indexSalesforceMetadataFile(read.text, filePath, repoPath, db, metadata);
5343
+ }
5344
+ else {
5345
+ const aura = auraBundleFile(filePath);
5346
+ const lwc = lwcBundleFile(filePath);
5347
+ if (!aura && !lwc)
5348
+ continue;
5349
+ if (!appBuilderApexDataSources(read.text, !!aura).some((entry) => classes.has(entry.name)))
5350
+ continue;
5351
+ if (aura)
5352
+ await indexAuraBundleFile(read.text, filePath, repoPath, db, aura);
5353
+ else if (lwc)
5354
+ await indexLwcBundleFile(read.text, filePath, repoPath, db, lwc);
5355
+ }
5356
+ recordIndexState(db, repoPath, filePath);
5357
+ refreshed.push(filePath);
5358
+ }
5359
+ return refreshed;
5360
+ }
4227
5361
  /**
4228
5362
  * Revisit only indexed Flow files that literally name an incrementally changed
4229
5363
  * Apex class. This keeps persisted method edges coherent when an annotation,
4230
5364
  * overload, or declaration changes without requiring a repository-wide scan.
4231
5365
  */
4232
- async function refreshFlowsForApexFiles(db, repoPath, apexFiles) {
5366
+ async function refreshFlowsForApexFiles(...args) {
5367
+ return extractionCoverage.run(args[0], () => refreshFlowsWithinCoverage(...args));
5368
+ }
5369
+ async function refreshFlowsWithinCoverage(db, repoPath, apexFiles) {
5370
+ const refreshed = await refreshSalesforceMetadataBindings(db, repoPath, apexFiles);
4233
5371
  const classNames = new Set(apexFiles
4234
5372
  .filter((file) => /^force-app\/main\/default\/classes\/[A-Za-z]\w*\.cls$/.test(file))
4235
5373
  .map((file) => path.basename(file, ".cls")));
4236
5374
  if (classNames.size === 0)
4237
- return [];
5375
+ return refreshed;
4238
5376
  const flowFiles = db
4239
5377
  .query("SELECT DISTINCT filePath FROM symbols WHERE kind = 'salesforce_flow' AND filePath LIKE 'force-app/main/default/flows/%.flow-meta.xml' ORDER BY filePath")
4240
5378
  .all();
4241
- const refreshed = [];
4242
5379
  for (const { filePath } of flowFiles) {
4243
5380
  const absolute = path.join(repoPath, filePath);
4244
5381
  let content;
@@ -4251,7 +5388,7 @@ async function refreshFlowsForApexFiles(db, repoPath, apexFiles) {
4251
5388
  const mentionsChangedClass = [...content.matchAll(/<actionCalls>([\s\S]*?)<\/actionCalls>/g)]
4252
5389
  .filter((match) => /<actionType>apex<\/actionType>/.test(match[1]))
4253
5390
  .some((match) => staticXmlTagValues(match[1], "actionName").some((entry) => {
4254
- const target = /^([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(entry.value);
5391
+ const target = /^([A-Za-z]\w*)(?:\.([A-Za-z]\w*))?$/.exec(entry.value);
4255
5392
  return target ? classNames.has(target[1]) : false;
4256
5393
  }));
4257
5394
  if (!mentionsChangedClass)
@@ -4271,6 +5408,17 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
4271
5408
  { to: bundle.bundlePath, kind: "lwc_bundle_member" },
4272
5409
  ];
4273
5410
  const references = [];
5411
+ if (bundle.extension === "js-meta.xml") {
5412
+ for (const entry of appBuilderApexDataSources(content, false)) {
5413
+ const target = findUniqueSalesforceBindingTarget(entry.name, repoPath, "apex");
5414
+ if (target)
5415
+ dependencies.push({
5416
+ to: target,
5417
+ kind: "appbuilder_apex_datasource",
5418
+ sourceEvidence: entry.evidence,
5419
+ });
5420
+ }
5421
+ }
4274
5422
  if (bundle.extension === "js") {
4275
5423
  const importedNames = new Map();
4276
5424
  for (const match of content.matchAll(/import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']/g)) {
@@ -4338,6 +5486,7 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
4338
5486
  }
4339
5487
  }
4340
5488
  if (bundle.extension === "html") {
5489
+ dependencies.push(...(await resolvedSalesforceComponentBindings(content, "lwc", repoPath)));
4341
5490
  const markup = content.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
4342
5491
  for (const match of markup.matchAll(/<(lightning-record-(?:form|view-form|edit-form|picker))\b/gi)) {
4343
5492
  dependencies.push({
@@ -4372,13 +5521,13 @@ async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
4372
5521
  }
4373
5522
  insertRef.finalize();
4374
5523
  const seen = new Set();
4375
- const insertDep = db.prepare("INSERT INTO dependencies (fromFile, toFile, kind, confidence) VALUES (?, ?, ?, ?)");
5524
+ const insertDep = db.prepare("INSERT INTO dependencies (fromFile, toFile, kind, confidence, sourceEvidence) VALUES (?, ?, ?, ?, ?)");
4376
5525
  for (const dependency of dependencies) {
4377
5526
  const key = `${dependency.to}\0${dependency.kind}`;
4378
5527
  if (seen.has(key))
4379
5528
  continue;
4380
5529
  seen.add(key);
4381
- insertDep.run(relativePath, dependency.to, dependency.kind, 1);
5530
+ insertDep.run(relativePath, dependency.to, dependency.kind, 1, dependency.sourceEvidence ?? null);
4382
5531
  }
4383
5532
  insertDep.finalize();
4384
5533
  db.run("COMMIT;");
@@ -5476,6 +6625,17 @@ async function indexAuraBundleFile(content, relativePath, repoPath, db, bundle)
5476
6625
  sourceEvidence: "Aura bundle file path",
5477
6626
  },
5478
6627
  ];
6628
+ if (bundle.extension === "design") {
6629
+ for (const entry of appBuilderApexDataSources(content, true)) {
6630
+ const target = findUniqueSalesforceBindingTarget(entry.name, repoPath, "apex");
6631
+ if (target)
6632
+ dependencies.push({
6633
+ to: target,
6634
+ kind: "appbuilder_apex_datasource",
6635
+ sourceEvidence: entry.evidence,
6636
+ });
6637
+ }
6638
+ }
5479
6639
  // Only component controller attributes name an Apex controller. `$A` lookups,
5480
6640
  // computed strings, and client-side action names deliberately produce no edge.
5481
6641
  if (bundle.extension === "cmp") {
@@ -5491,17 +6651,9 @@ async function indexAuraBundleFile(content, relativePath, repoPath, db, bundle)
5491
6651
  sourceEvidence: `controller="${controller}"`,
5492
6652
  });
5493
6653
  }
5494
- // An Aura markup tag in the local `c` namespace is a declared, static LWC
5495
- // target only when the matching Salesforce DX LWC entry point exists.
5496
- for (const match of markup.matchAll(/<c:([A-Za-z]\w*)\b/g)) {
5497
- const target = findSalesforceLwcComponentFile(match[1], repoPath);
5498
- if (target)
5499
- dependencies.push({
5500
- to: target,
5501
- kind: "aura_lwc_component",
5502
- sourceEvidence: match[0],
5503
- });
5504
- }
6654
+ }
6655
+ if (bundle.extension === "cmp" || bundle.extension === "app") {
6656
+ dependencies.push(...(await resolvedSalesforceComponentBindings(content, "aura", repoPath)));
5505
6657
  }
5506
6658
  db.run("BEGIN TRANSACTION;");
5507
6659
  try {
@@ -5616,10 +6768,87 @@ async function indexSalesforceExperienceFile(content, relativePath, repoPath, db
5616
6768
  throw error;
5617
6769
  }
5618
6770
  }
6771
+ /** Static Lightning Out declarations, never evidence that the call executed. */
6772
+ async function visualforceLightningBindings(content) {
6773
+ const scripts = visualforceInlineScripts(content);
6774
+ if (!scripts?.length)
6775
+ return [];
6776
+ const language = await getLanguageForFile("lightning-out.js");
6777
+ if (!language)
6778
+ return [];
6779
+ const parser = new Parser();
6780
+ parser.setLanguage(language);
6781
+ const bindings = [];
6782
+ try {
6783
+ for (const source of scripts) {
6784
+ const tree = parser.parse(source);
6785
+ try {
6786
+ if (tree.rootNode.hasError())
6787
+ return [];
6788
+ const queue = [tree.rootNode];
6789
+ for (let i = 0; i < queue.length; i++) {
6790
+ const node = queue[i];
6791
+ // References as well as direct calls can hide indirect eval or a
6792
+ // Function constructor; computed browser-global access can hide
6793
+ // a fragmented "$" + "Lightning" assignment. Fail closed.
6794
+ if ((["identifier", "property_identifier"].includes(node.type) &&
6795
+ ["eval", "Function"].includes(node.text)) ||
6796
+ (node.type === "subscript_expression" &&
6797
+ ["window", "globalThis", "self", "this"].includes(node.childForFieldName("object")?.text ?? "")))
6798
+ return [];
6799
+ if ((node.type === "identifier" || node.type === "shorthand_property_identifier_pattern") &&
6800
+ node.text === "$Lightning") {
6801
+ const parent = node.parent;
6802
+ if (parent?.type !== "member_expression" ||
6803
+ parent.childForFieldName("object")?.id !== node.id)
6804
+ return [];
6805
+ }
6806
+ if ((node.type === "property_identifier" && node.text === "$Lightning") ||
6807
+ (node.type === "subscript_expression" && /\$Lightning/.test(node.text)))
6808
+ return [];
6809
+ if ([
6810
+ "assignment_expression",
6811
+ "augmented_assignment_expression",
6812
+ "update_expression",
6813
+ "unary_expression",
6814
+ ].includes(node.type) &&
6815
+ /\$Lightning/.test((node.childForFieldName("left") ?? node.childForFieldName("argument") ?? node).text))
6816
+ return [];
6817
+ if (node.type === "with_statement" ||
6818
+ (node.type === "call_expression" &&
6819
+ ["eval", "Function"].includes(node.childForFieldName("function")?.text ?? "")))
6820
+ return [];
6821
+ if (node.type === "call_expression") {
6822
+ const call = node.childForFieldName("function");
6823
+ const argument = node.childForFieldName("arguments")?.namedChild(0);
6824
+ if (call?.type === "member_expression" &&
6825
+ call.text === "$Lightning.use" &&
6826
+ argument?.type === "string") {
6827
+ const name = /^["']c:([A-Za-z]\w*)["']$/.exec(argument.text)?.[1];
6828
+ if (name && !name.includes("__"))
6829
+ bindings.push({ name, evidence: `${call.text}(${argument.text}` });
6830
+ }
6831
+ }
6832
+ queue.push(...node.namedChildren);
6833
+ }
6834
+ }
6835
+ finally {
6836
+ tree.delete();
6837
+ }
6838
+ }
6839
+ return [...new Map(bindings.map((binding) => [binding.name, binding])).values()];
6840
+ }
6841
+ finally {
6842
+ parser.delete();
6843
+ }
6844
+ }
5619
6845
  async function indexVisualforceFile(content, tree, relativePath, repoPath, db) {
5620
6846
  const controllerNames = new Set();
5621
6847
  const extensionNames = [];
5622
6848
  const references = [];
6849
+ const lightning = /^force-app\/main\/default\/pages\/[A-Za-z]\w*\.page$/.test(relativePath)
6850
+ ? await visualforceLightningBindings(content)
6851
+ : [];
5623
6852
  function traverseXML(node) {
5624
6853
  if (node.type === "element" || node.type === "start_tag") {
5625
6854
  const tagNameNode = node.childForFieldName("name") || node.children.find((c) => c.type === "tag_name");
@@ -5747,6 +6976,14 @@ async function indexVisualforceFile(content, tree, relativePath, repoPath, db) {
5747
6976
  const target = resolvedPath || className;
5748
6977
  insertDep.run(relativePath, target, "controller", 1, `${attribute}="${className}"`);
5749
6978
  }
6979
+ for (const binding of lightning) {
6980
+ const target = salesforceBindingTargets(repoPath).components.resolve({
6981
+ target: "application",
6982
+ name: binding.name,
6983
+ });
6984
+ if (target)
6985
+ insertDep.run(relativePath, target.file, "visualforce_lightning_out", 1, binding.evidence);
6986
+ }
5750
6987
  insertDep.finalize();
5751
6988
  db.run("COMMIT;");
5752
6989
  }
@@ -6260,9 +7497,10 @@ function buildFileIndexResult(tree, content, isPython, isApex, absolutePath, rel
6260
7497
  return null;
6261
7498
  };
6262
7499
  const resolvedReferences = references.map((ref) => ({
7500
+ apexCall: ref.apexCall,
6263
7501
  callerSymbol: ref.callerSymbol,
6264
7502
  calleeSymbol: ref.calleeSymbol,
6265
- calleeFile: resolveCalleeFile(ref.calleeSymbol, ref.calleeModule),
7503
+ calleeFile: ref.apexCall ? null : resolveCalleeFile(ref.calleeSymbol, ref.calleeModule),
6266
7504
  line: ref.line,
6267
7505
  column: ref.column,
6268
7506
  kind: ref.kind ?? "call",
@@ -6293,6 +7531,10 @@ function buildFileIndexResult(tree, content, isPython, isApex, absolutePath, rel
6293
7531
  return {
6294
7532
  relativePath,
6295
7533
  mcpRegistrations,
7534
+ ...(isApex
7535
+ ? { apexClassUses: extractApexClassUses(tree.rootNode, content, relativePath) }
7536
+ : {}),
7537
+ ...(isApex ? { apexReceiverVersion: 1 } : {}),
6296
7538
  definitions,
6297
7539
  references: resolvedReferences,
6298
7540
  apiContracts,
@@ -6323,6 +7565,26 @@ function writeFileIndexResult(db, result) {
6323
7565
  /** The actual delete+insert statements, run inside a transaction/savepoint the caller owns. */
6324
7566
  function writeFileIndexResultUnwrapped(db, result) {
6325
7567
  const { relativePath, mcpRegistrations, definitions, references, apiContracts, dependencies } = result;
7568
+ db.run("DELETE FROM apex_consumer_files WHERE filePath = ?", [relativePath]);
7569
+ db.run("DELETE FROM apex_class_uses WHERE filePath = ?", [relativePath]);
7570
+ if (result.apexClassUses) {
7571
+ const facts = result.apexClassUses;
7572
+ const serialized = JSON.stringify(facts);
7573
+ db.run("INSERT INTO apex_consumer_files(filePath,version,sourceHash,parseState,isTest,facts,factsHash) VALUES (?,1,?,?,?,?,?)", [
7574
+ relativePath,
7575
+ facts.sourceHash,
7576
+ facts.parseState,
7577
+ facts.isTest === null ? null : facts.isTest ? 1 : 0,
7578
+ serialized,
7579
+ crypto.createHash("sha256").update(serialized).digest("hex"),
7580
+ ]);
7581
+ setMeta(db, "apexConsumerFactsRevision", crypto.randomUUID());
7582
+ }
7583
+ db.run("DELETE FROM apex_file_capabilities WHERE filePath = ?", [relativePath]);
7584
+ if (result.apexReceiverVersion === 1)
7585
+ db.run("INSERT OR REPLACE INTO apex_file_capabilities(filePath,version) VALUES (?,1)", [
7586
+ relativePath,
7587
+ ]);
6326
7588
  db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
6327
7589
  deleteSymbolsForFile(db, relativePath);
6328
7590
  db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
@@ -6337,19 +7599,19 @@ function writeFileIndexResultUnwrapped(db, result) {
6337
7599
  }
6338
7600
  insertMcp.finalize();
6339
7601
  const insertSym = db.prepare(`
6340
- INSERT INTO symbols (name, kind, filePath, startLine, endLine, startCol, endCol, summary)
6341
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
7602
+ INSERT INTO symbols (name, kind, filePath, startLine, endLine, startCol, endCol, summary, apexDeclaration)
7603
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
6342
7604
  `);
6343
7605
  for (const def of definitions) {
6344
- insertSym.run(def.name, def.kind, relativePath, def.startLine, def.endLine, def.startCol, def.endCol, def.summary);
7606
+ insertSym.run(def.name, def.kind, relativePath, def.startLine, def.endLine, def.startCol, def.endCol, def.summary, def.apexDeclaration ?? null);
6345
7607
  }
6346
7608
  insertSym.finalize();
6347
7609
  const insertRef = db.prepare(`
6348
- INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind)
6349
- VALUES (?, ?, ?, ?, ?, ?, ?)
7610
+ INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind, apexCall)
7611
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6350
7612
  `);
6351
7613
  for (const ref of references) {
6352
- insertRef.run(ref.callerSymbol, relativePath, ref.calleeSymbol, ref.calleeFile, ref.line, ref.column, ref.kind);
7614
+ insertRef.run(ref.callerSymbol, relativePath, ref.calleeSymbol, ref.calleeFile, ref.line, ref.column, ref.kind, ref.apexCall ?? null);
6353
7615
  }
6354
7616
  insertRef.finalize();
6355
7617
  const insertApiContract = db.prepare(`
@@ -6470,6 +7732,34 @@ function freeQuietly(target) {
6470
7732
  // Intentionally ignored; see above.
6471
7733
  }
6472
7734
  }
7735
+ function incompleteApexExtraction(content, relativePath, reason) {
7736
+ const facts = {
7737
+ version: 1,
7738
+ file: relativePath,
7739
+ sourceHash: crypto.createHash("sha256").update(content).digest("hex"),
7740
+ parseState: "partial",
7741
+ isTest: null,
7742
+ sites: [],
7743
+ declarations: [],
7744
+ unsupportedUses: [],
7745
+ coverage: "supported-syntax-only",
7746
+ omissionReason: reason,
7747
+ };
7748
+ return {
7749
+ kind: "result",
7750
+ result: {
7751
+ relativePath,
7752
+ apexClassUses: facts,
7753
+ apexClassUseIncompleteReason: reason,
7754
+ mcpRegistrations: [],
7755
+ definitions: [],
7756
+ references: [],
7757
+ apiContracts: [],
7758
+ dependencies: [],
7759
+ apexPlatformDependencies: [],
7760
+ },
7761
+ };
7762
+ }
6473
7763
  /**
6474
7764
  * Read, parse, and extract one file through the generic tree-sitter path.
6475
7765
  *
@@ -6479,15 +7769,23 @@ function freeQuietly(target) {
6479
7769
  * through here so routing can never drift between them.
6480
7770
  */
6481
7771
  export async function extractGenericFileForIndex(absolutePath, relativePath, repoPath) {
7772
+ const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6482
7773
  const lang = await measurePerfPhase("parser_wasm_init", () => getLanguageForFile(absolutePath));
6483
7774
  if (!lang)
6484
- return { kind: "unparsed" };
7775
+ return isApex
7776
+ ? incompleteApexExtraction(fs.readFileSync(absolutePath, "utf8"), relativePath, "parser-unavailable")
7777
+ : { kind: "unparsed" };
6485
7778
  const content = measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
6486
7779
  // Skip minified / generated bundles (e.g. a `*.bundle.js`): they're not
6487
7780
  // readable source and would inject thousands of junk "symbols" that pollute
6488
7781
  // the graph, communities, and hubs. Heuristic: a very high average line
6489
7782
  // length (minified code packs everything onto a few enormous lines).
6490
- if (isMinifiedSource(content))
7783
+ // Apex response fixtures legitimately contain huge single-line string literals. Line
7784
+ // density is not a sound minification signal for that language; preserve the existing
7785
+ // absolute source-length bound and record its omission rather than inventing empty coverage.
7786
+ if (isApex && content.length > 1_000_000)
7787
+ return incompleteApexExtraction(content, relativePath, "source-size-limit");
7788
+ if (!isApex && isMinifiedSource(content))
6491
7789
  return { kind: "unparsed" };
6492
7790
  const isSql = absolutePath.endsWith(".sql") || absolutePath.endsWith(".pkb") || absolutePath.endsWith(".pks");
6493
7791
  // SQL: neutralize dollar-quoted bodies that crash the grammar's scanner.
@@ -6514,10 +7812,14 @@ export async function extractGenericFileForIndex(absolutePath, relativePath, rep
6514
7812
  // to an abort through the recovery path itself.
6515
7813
  freeQuietly(parser);
6516
7814
  console.warn(`knodin: skipped ${relativePath} (parser error: ${e.message})`);
7815
+ if (isApex)
7816
+ return incompleteApexExtraction(content, relativePath, "parser-failure");
6517
7817
  return { kind: "skipped", reason: e.message };
6518
7818
  }
6519
7819
  if (!tree) {
6520
7820
  freeQuietly(parser);
7821
+ if (isApex)
7822
+ return incompleteApexExtraction(content, relativePath, "parser-failure");
6521
7823
  return { kind: "skipped", reason: "parser returned no tree" };
6522
7824
  }
6523
7825
  // Everything from here on can throw — `extractMcpToolRegistrations` and the
@@ -6531,7 +7833,6 @@ export async function extractGenericFileForIndex(absolutePath, relativePath, rep
6531
7833
  ? extractMcpToolRegistrations(content, relativePath, tree.rootNode)
6532
7834
  : [];
6533
7835
  const isPython = absolutePath.endsWith(".py");
6534
- const isApex = absolutePath.endsWith(".cls") || absolutePath.endsWith(".trigger");
6535
7836
  const isVisualforce = absolutePath.endsWith(".page") || absolutePath.endsWith(".component");
6536
7837
  const isPrisma = absolutePath.endsWith(".prisma");
6537
7838
  const isWorkday = absolutePath.endsWith(".clp") ||
@@ -6670,7 +7971,14 @@ const SPECIAL_ROUTE_EXTENSIONS = [
6670
7971
  ".pks",
6671
7972
  ".prisma",
6672
7973
  ];
6673
- async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, collectResult) {
7974
+ const extractionCoverage = new AsyncLocalStorage();
7975
+ async function indexFile(...args) {
7976
+ const [, , , db] = args;
7977
+ if (!coverageContains(requireIndexCoverage(coverageForDatabase(db)), args[1]))
7978
+ throw new Error(`knodin: refusing to index outside declared coverage: ${args[1]}`);
7979
+ return extractionCoverage.run(db, () => indexFileInCoverage(...args));
7980
+ }
7981
+ async function indexFileInCoverage(absolutePath, relativePath, repoPath, db, unparsed, collectResult) {
6674
7982
  try {
6675
7983
  invalidateScipFacts(db);
6676
7984
  const read = () => measurePerfPhaseSync("read", () => fs.readFileSync(absolutePath, "utf-8"));
@@ -6765,6 +8073,8 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6765
8073
  }
6766
8074
  return;
6767
8075
  }
8076
+ if (extraction.result.apexClassUseIncompleteReason && unparsed)
8077
+ tallyOne(unparsed, extensionBucket(relativePath));
6768
8078
  if (collectResult) {
6769
8079
  collectResult(extraction.result);
6770
8080
  }
@@ -6792,7 +8102,7 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed, col
6792
8102
  * stale wiring atomically without a second schema or cache.
6793
8103
  */
6794
8104
  function reconcileTypeScriptDi(db, repoPath) {
6795
- const files = collectRepoFiles(repoPath).filter((file) => (file.endsWith(".ts") || file.endsWith(".tsx")) && !file.endsWith(".d.ts"));
8105
+ const files = collectRepoFiles(repoPath, db).filter((file) => (file.endsWith(".ts") || file.endsWith(".tsx")) && !file.endsWith(".d.ts"));
6796
8106
  const frameworkFiles = files.filter((file) => {
6797
8107
  try {
6798
8108
  const source = fs.readFileSync(path.join(repoPath, file), "utf8");
@@ -7381,10 +8691,10 @@ async function indexEmbeddings(db, repoPath, progress, signal) {
7381
8691
  progress?.("embedding-symbols", 0, `Embedding ${missing.length.toLocaleString()} symbols`, {
7382
8692
  phaseTotal: missing.length,
7383
8693
  });
7384
- progress?.("embedding-model", 0, "Preparing local embedding model");
7385
8694
  let completedEmbeddings = 0;
8695
+ let modelProgressComplete = false;
7386
8696
  const onModelProgress = (event) => {
7387
- if (completedEmbeddings > 0)
8697
+ if (modelProgressComplete)
7388
8698
  return;
7389
8699
  const completed = event.loaded ?? 0;
7390
8700
  const total = event.total;
@@ -7404,6 +8714,8 @@ async function indexEmbeddings(db, repoPath, progress, signal) {
7404
8714
  `);
7405
8715
  const batchSize = embeddingBatchSize();
7406
8716
  const sourceForEmbedding = createEmbeddingSourceReader(repoPath);
8717
+ const reuse = new EmbeddingReuseCache(db);
8718
+ const modelIdentity = getEmbeddingCacheIdentity();
7407
8719
  let lastYieldAt = Date.now();
7408
8720
  for (let offset = 0; offset < missing.length; offset += batchSize) {
7409
8721
  throwIfAborted(signal);
@@ -7411,14 +8723,33 @@ async function indexEmbeddings(db, repoPath, progress, signal) {
7411
8723
  const source = sourceForEmbedding(sym.filePath, sym.startLine, sym.endLine);
7412
8724
  return { symbol: sym, text: embeddingInputFor(sym, source) };
7413
8725
  });
7414
- const generated = await embedPreparedBatch(prepared, onModelProgress);
8726
+ const cached = [];
8727
+ const uncached = [];
8728
+ const keys = new Map();
8729
+ for (const item of prepared) {
8730
+ const key = embeddingReuseKey(modelIdentity, item.text);
8731
+ keys.set(item.symbol.id, key);
8732
+ const embedding = reuse.get(key);
8733
+ if (embedding)
8734
+ cached.push({ symbol: item.symbol, embedding });
8735
+ else
8736
+ uncached.push(item);
8737
+ }
8738
+ const generated = [
8739
+ ...cached,
8740
+ ...(uncached.length ? await embedPreparedBatch(uncached, onModelProgress) : []),
8741
+ ];
8742
+ if (uncached.length > 0)
8743
+ modelProgressComplete = true;
7415
8744
  throwIfAborted(signal);
7416
8745
  db.run("BEGIN TRANSACTION;");
7417
8746
  try {
7418
8747
  for (const { symbol, embedding: embeddingVec } of generated) {
7419
8748
  const u8 = new Uint8Array(embeddingVec.buffer, embeddingVec.byteOffset, embeddingVec.byteLength);
7420
8749
  insertEmbedding.run(symbol.id, u8);
8750
+ reuse.put(keys.get(symbol.id), embeddingVec);
7421
8751
  }
8752
+ reuse.prune();
7422
8753
  db.run("COMMIT;");
7423
8754
  }
7424
8755
  catch (error) {
@@ -7460,8 +8791,8 @@ function extensionBucket(relativePath) {
7460
8791
  function tallyOne(tally, bucket) {
7461
8792
  tally.set(bucket, (tally.get(bucket) ?? 0) + 1);
7462
8793
  }
7463
- function collectRepoFiles(repoPath) {
7464
- return collectRepoFilesWithCoverage(repoPath).files;
8794
+ function collectRepoFiles(repoPath, db) {
8795
+ return collectRepoFilesWithCoverage(repoPath, db).files;
7465
8796
  }
7466
8797
  /** Descending-count record, so the largest coverage gap reads first. */
7467
8798
  function sortedTally(tally) {
@@ -7664,7 +8995,8 @@ function isIndexableJsonCandidate(file, repoPath) {
7664
8995
  return false;
7665
8996
  }
7666
8997
  }
7667
- function gitTrackedCandidates(repoPath) {
8998
+ function gitTrackedCandidates(repoPath, intent) {
8999
+ let gitConfirmed = false;
7668
9000
  try {
7669
9001
  const gitRoot = child_process
7670
9002
  .execFileSync("git", ["rev-parse", "--show-toplevel"], {
@@ -7675,21 +9007,31 @@ function gitTrackedCandidates(repoPath) {
7675
9007
  .trim();
7676
9008
  if (!gitRoot || !isSameDir(gitRoot, repoPath))
7677
9009
  return null;
7678
- return splitNulPaths(child_process.execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
7679
- cwd: repoPath,
7680
- encoding: "utf8",
7681
- stdio: ["ignore", "pipe", "ignore"],
7682
- maxBuffer: 64 * 1024 * 1024,
7683
- })).filter((file) => {
9010
+ gitConfirmed = true;
9011
+ let candidates = [];
9012
+ for (const pathspecs of coverageGitPathspecBatches(intent))
9013
+ candidates = candidates.concat(splitNulPaths(child_process.execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard", ...pathspecs], {
9014
+ cwd: repoPath,
9015
+ encoding: "utf8",
9016
+ stdio: ["ignore", "pipe", "ignore"],
9017
+ maxBuffer: 64 * 1024 * 1024,
9018
+ })));
9019
+ return (intent?.mode === "scoped" ? [...new Set(candidates)] : candidates).filter((file) => {
9020
+ if (intent && !coverageContains(intent, file))
9021
+ return false;
7684
9022
  try {
7685
- return fs.statSync(path.join(repoPath, file)).isFile();
9023
+ return intent?.mode === "scoped"
9024
+ ? (safeCoveragePath(repoPath, file)?.isFile() ?? false)
9025
+ : fs.statSync(path.join(repoPath, file)).isFile();
7686
9026
  }
7687
9027
  catch {
7688
9028
  return false;
7689
9029
  }
7690
9030
  });
7691
9031
  }
7692
- catch {
9032
+ catch (error) {
9033
+ if (intent?.mode === "scoped" && (gitConfirmed || fs.existsSync(path.join(repoPath, ".git"))))
9034
+ throw new Error("knodin: scoped Git file enumeration failed; refusing an unfiltered filesystem fallback", { cause: error });
7693
9035
  return null;
7694
9036
  }
7695
9037
  }
@@ -7708,29 +9050,41 @@ async function execGitAbortable(repoPath, args, signal) {
7708
9050
  : resolve(stdout));
7709
9051
  });
7710
9052
  }
7711
- async function collectRepoFilesWithCoverageAbortable(repoPath, signal) {
9053
+ async function collectRepoFilesWithCoverageAbortable(repoPath, signal, db) {
9054
+ const intent = requireIndexCoverage(coverageForDatabase(db ?? dbInstances.get(path.resolve(repoPath))));
7712
9055
  let candidates = null;
9056
+ let gitConfirmed = false;
7713
9057
  try {
7714
9058
  const gitRoot = (await execGitAbortable(repoPath, ["rev-parse", "--show-toplevel"], signal)).trim();
7715
9059
  if (gitRoot && isSameDir(gitRoot, repoPath)) {
7716
- candidates = splitNulPaths(await execGitAbortable(repoPath, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], signal));
9060
+ gitConfirmed = true;
9061
+ let selected = [];
9062
+ for (const pathspecs of coverageGitPathspecBatches(intent))
9063
+ selected = selected.concat(splitNulPaths(await execGitAbortable(repoPath, ["ls-files", "-z", "--cached", "--others", "--exclude-standard", ...pathspecs], signal)));
9064
+ candidates = intent.mode === "scoped" ? [...new Set(selected)] : selected;
7717
9065
  }
7718
9066
  }
7719
- catch {
9067
+ catch (error) {
7720
9068
  throwIfAborted(signal);
9069
+ if (intent.mode === "scoped" && (gitConfirmed || fs.existsSync(path.join(repoPath, ".git"))))
9070
+ throw new Error("knodin: scoped Git file enumeration failed; refusing an unfiltered filesystem fallback", { cause: error });
7721
9071
  }
7722
9072
  const files = [];
7723
9073
  const skippedByExtension = new Map();
7724
- const source = candidates ?? walkRepoFiles(repoPath);
9074
+ const source = candidates ?? walkCoverageFiles(repoPath, intent);
7725
9075
  for (let index = 0; index < source.length; index++) {
7726
9076
  if (index % 256 === 0) {
7727
9077
  throwIfAborted(signal);
7728
9078
  await yieldToIndexEventLoop();
7729
9079
  }
7730
9080
  const file = source[index];
9081
+ if (!coverageContains(intent, file))
9082
+ continue;
7731
9083
  if (candidates) {
7732
9084
  try {
7733
- if (!fs.statSync(path.join(repoPath, file)).isFile())
9085
+ if (!(intent.mode === "scoped"
9086
+ ? safeCoveragePath(repoPath, file)?.isFile()
9087
+ : fs.statSync(path.join(repoPath, file)).isFile()))
7734
9088
  continue;
7735
9089
  }
7736
9090
  catch {
@@ -7755,17 +9109,21 @@ async function collectRepoFilesWithCoverageAbortable(repoPath, signal) {
7755
9109
  * `manifest.json` files and Dockerfiles. Shared by the full index and the
7756
9110
  * cold-start reconcile so both see the identical candidate set.
7757
9111
  */
7758
- function collectRepoFilesWithCoverage(repoPath) {
9112
+ function collectRepoFilesWithCoverage(repoPath, db, requestedIntent) {
9113
+ const intent = requestedIntent ??
9114
+ requireIndexCoverage(coverageForDatabase(db ?? dbInstances.get(path.resolve(repoPath))));
7759
9115
  const files = [];
7760
9116
  const skippedByExtension = new Map();
7761
- const candidates = gitTrackedCandidates(repoPath);
9117
+ const candidates = gitTrackedCandidates(repoPath, intent);
7762
9118
  // The walk deliberately does NOT pre-filter with `accept`: both branches must
7763
9119
  // see the same candidate superset so the skip tally below counts identically
7764
9120
  // whether or not the repository is a Git work tree. (main passed
7765
9121
  // `{ accept: isIndexableSourcePath }` here; filtering during the walk would
7766
9122
  // make the excluded files invisible to the tally, which is the whole point of
7767
9123
  // this function.)
7768
- for (const file of candidates ?? walkRepoFiles(repoPath)) {
9124
+ for (const file of candidates ?? walkCoverageFiles(repoPath, intent)) {
9125
+ if (!coverageContains(intent, file))
9126
+ continue;
7769
9127
  // Pruned infrastructure (node_modules, dist, .git, state dirs) is not a
7770
9128
  // coverage gap and is never counted as one.
7771
9129
  if (!isIndexablePath(file))
@@ -7792,7 +9150,7 @@ function collectRepoFilesWithCoverage(repoPath) {
7792
9150
  function gitHead(repoPath) {
7793
9151
  try {
7794
9152
  const out = child_process
7795
- .execSync("git rev-parse HEAD", {
9153
+ .execFileSync("git", ["rev-parse", "HEAD"], {
7796
9154
  cwd: repoPath,
7797
9155
  encoding: "utf-8",
7798
9156
  stdio: ["pipe", "pipe", "ignore"],
@@ -7800,28 +9158,119 @@ function gitHead(repoPath) {
7800
9158
  .trim();
7801
9159
  return out || null;
7802
9160
  }
7803
- catch {
7804
- return null;
9161
+ catch {
9162
+ return null;
9163
+ }
9164
+ }
9165
+ /** Read a value from the `meta` key/value table (null if absent). */
9166
+ function getMeta(db, key) {
9167
+ try {
9168
+ const row = db
9169
+ .query("SELECT value FROM meta WHERE key = ?")
9170
+ .get(key);
9171
+ return row?.value ?? null;
9172
+ }
9173
+ catch {
9174
+ return null;
9175
+ }
9176
+ }
9177
+ /** Upsert a value into the `meta` key/value table. */
9178
+ function setMeta(db, key, value) {
9179
+ try {
9180
+ db.run("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, value]);
9181
+ }
9182
+ catch (_) { }
9183
+ }
9184
+ const coverageStates = new WeakMap();
9185
+ function coverageForDatabase(db) {
9186
+ if (!db)
9187
+ return readIndexCoverage(null, null, null);
9188
+ const values = [
9189
+ getMeta(db, INDEX_COVERAGE_KEY),
9190
+ getMeta(db, INDEX_COVERAGE_PENDING_KEY),
9191
+ getMeta(db, INDEX_COVERAGE_COMPLETE_KEY),
9192
+ ];
9193
+ const key = JSON.stringify(values);
9194
+ const cached = coverageStates.get(db);
9195
+ if (cached?.key === key)
9196
+ return cached.state;
9197
+ const state = readIndexCoverage(...values);
9198
+ coverageStates.set(db, { key, state });
9199
+ return state;
9200
+ }
9201
+ function writeCoverageIntent(db, repoPath, intent) {
9202
+ // Unlike best-effort informational meta, scope intent must commit or fail BEFORE work.
9203
+ db.run("BEGIN TRANSACTION;");
9204
+ try {
9205
+ for (const [key, value] of [
9206
+ [INDEX_COVERAGE_KEY, JSON.stringify(intent)],
9207
+ [INDEX_COVERAGE_PENDING_KEY, String(intent.revision)],
9208
+ ])
9209
+ db.run("INSERT INTO meta(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [key, value]);
9210
+ db.run("COMMIT;");
9211
+ }
9212
+ catch (error) {
9213
+ db.run("ROLLBACK;");
9214
+ throw error;
9215
+ }
9216
+ freshnessProbes.delete(path.resolve(repoPath));
9217
+ statusCache.delete(path.resolve(repoPath));
9218
+ invalidateResolutionCaches(repoPath);
9219
+ indexGeneration++;
9220
+ }
9221
+ function markCoverageIntentComplete(db) {
9222
+ const state = coverageForDatabase(db);
9223
+ if (state.origin !== "explicit" || !state.intent)
9224
+ return;
9225
+ db.run("INSERT INTO meta(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [INDEX_COVERAGE_COMPLETE_KEY, String(state.intent.revision)]);
9226
+ db.run("DELETE FROM meta WHERE key = ?", [INDEX_COVERAGE_PENDING_KEY]);
9227
+ }
9228
+ function completeCoverageIntent(db) {
9229
+ db.run("BEGIN TRANSACTION;");
9230
+ try {
9231
+ markCoverageIntentComplete(db);
9232
+ db.run("COMMIT;");
9233
+ }
9234
+ catch (error) {
9235
+ db.run("ROLLBACK;");
9236
+ throw error;
7805
9237
  }
7806
9238
  }
7807
- /** Read a value from the `meta` key/value table (null if absent). */
7808
- function getMeta(db, key) {
9239
+ /** No selector can follow a symlink, including a symlink in a parent directory. */
9240
+ function safeCoveragePath(repoPath, file) {
9241
+ if (!canonicalCoveragePath(file) || !isIndexablePath(file))
9242
+ return null;
9243
+ let absolute = repoPath;
9244
+ let stat = null;
7809
9245
  try {
7810
- const row = db
7811
- .query("SELECT value FROM meta WHERE key = ?")
7812
- .get(key);
7813
- return row?.value ?? null;
9246
+ for (const part of file.split("/")) {
9247
+ absolute = path.join(absolute, part);
9248
+ stat = fs.lstatSync(absolute);
9249
+ if (stat.isSymbolicLink())
9250
+ return null;
9251
+ }
9252
+ return stat;
7814
9253
  }
7815
9254
  catch {
7816
9255
  return null;
7817
9256
  }
7818
9257
  }
7819
- /** Upsert a value into the `meta` key/value table. */
7820
- function setMeta(db, key, value) {
7821
- try {
7822
- db.run("INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, value]);
9258
+ function walkCoverageFiles(repoPath, intent) {
9259
+ if (intent.mode === "full")
9260
+ return walkRepoFiles(repoPath);
9261
+ const files = new Set();
9262
+ for (const file of intent.files)
9263
+ if (safeCoveragePath(repoPath, file)?.isFile())
9264
+ files.add(file);
9265
+ for (const prefix of intent.prefixes) {
9266
+ const stat = safeCoveragePath(repoPath, prefix);
9267
+ if (stat?.isFile())
9268
+ files.add(prefix);
9269
+ else if (stat?.isDirectory())
9270
+ for (const file of walkRepoFiles(path.join(repoPath, prefix)))
9271
+ files.add(`${prefix}/${file}`);
7823
9272
  }
7824
- catch (_) { }
9273
+ return [...files].sort(compareBytes);
7825
9274
  }
7826
9275
  function commitRelation(repoPath, indexedHead, currentHead) {
7827
9276
  if (!indexedHead || !currentHead)
@@ -7856,16 +9305,21 @@ function freshnessFingerprint(value) {
7856
9305
  return crypto.createHash("sha256").update(value).digest("hex");
7857
9306
  }
7858
9307
  function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProbe) {
7859
- const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath) : gitProbe;
9308
+ const coverage = coverageForDatabase(db);
9309
+ const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath, coverage.intent ?? undefined) : gitProbe;
9310
+ const intent = coverage.intent;
9311
+ const scopedDirty = intent
9312
+ ? git?.dirty.filter((file) => coverageContains(intent, file))
9313
+ : undefined;
7860
9314
  const currentHead = git && isSameDir(git.root, repoPath) ? git.head || null : gitHead(repoPath);
7861
9315
  const indexedHead = db ? getMeta(db, "lastIndexedHead") || null : null;
7862
9316
  const relation = commitRelation(repoPath, indexedHead, currentHead);
7863
9317
  const baseline = db ? getMeta(db, "lastProbeDirty") : null;
7864
- const pendingPaths = git && isSameDir(git.root, repoPath) ? git.dirty.length : null;
9318
+ const pendingPaths = git && isSameDir(git.root, repoPath) ? (scopedDirty?.length ?? null) : null;
7865
9319
  const workingTreeChanged = git !== null &&
7866
9320
  isSameDir(git.root, repoPath) &&
7867
9321
  baseline !== null &&
7868
- baseline !== git.dirty.join("\n");
9322
+ baseline !== (scopedDirty ? coverageDirtyFingerprint(coverage.intent, scopedDirty) : undefined);
7869
9323
  const state = stateOverride ??
7870
9324
  (currentHead === null && indexedHead === null && baseline !== null
7871
9325
  ? workingTreeChanged
@@ -7880,6 +9334,7 @@ function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProb
7880
9334
  : "fresh");
7881
9335
  return {
7882
9336
  currentHead,
9337
+ indexCoverage: indexCoverageSummary(coverage),
7883
9338
  indexedHead,
7884
9339
  ...relation,
7885
9340
  workingTree: {
@@ -7963,6 +9418,10 @@ function removeIndexState(db, relPath) {
7963
9418
  if (!shouldPurgeMissingFile(db))
7964
9419
  return;
7965
9420
  try {
9421
+ db.run("DELETE FROM apex_consumer_files WHERE filePath = ?", [relPath]);
9422
+ db.run("DELETE FROM apex_class_uses WHERE filePath = ?", [relPath]);
9423
+ if (/\.(?:cls|trigger)$/i.test(relPath))
9424
+ setMeta(db, "apexConsumerFactsRevision", crypto.randomUUID());
7966
9425
  db.run("DELETE FROM index_state WHERE filePath = ?", [relPath]);
7967
9426
  }
7968
9427
  catch (_) { }
@@ -8035,8 +9494,9 @@ function detectDriftByStat(repoPath, db, canonicalFiles) {
8035
9494
  }
8036
9495
  return changed;
8037
9496
  }
8038
- async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
9497
+ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = getMeta(db, "semanticDeferred") === "1") {
8039
9498
  try {
9499
+ const coverage = requireIndexCoverage(coverageForDatabase(db));
8040
9500
  progress?.("collecting-files", 0, "Checking existing local graph for changes");
8041
9501
  const changed = new Set();
8042
9502
  const head = gitHead(repoPath);
@@ -8045,26 +9505,37 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8045
9505
  // --- git fast path ---
8046
9506
  const lastHead = getMeta(db, "lastIndexedHead");
8047
9507
  try {
8048
- const status = child_process.execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
8049
- cwd: repoPath,
8050
- encoding: "utf-8",
8051
- stdio: ["ignore", "pipe", "ignore"],
8052
- });
8053
- for (const file of parseGitStatusPaths(status))
8054
- changed.add(file);
8055
- }
8056
- catch (_) { }
8057
- let baselineUnresolved = false;
8058
- if (lastHead && lastHead !== head) {
8059
- try {
8060
- const diff = child_process.execFileSync("git", ["diff", "--name-only", "-z", lastHead, head, "--"], {
9508
+ for (const pathspecs of coverageGitPathspecBatches(coverage)) {
9509
+ const status = child_process.execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all", ...pathspecs], {
8061
9510
  cwd: repoPath,
8062
9511
  encoding: "utf-8",
8063
9512
  stdio: ["ignore", "pipe", "ignore"],
8064
9513
  });
8065
- for (const file of splitNulPaths(diff))
9514
+ for (const file of parseGitStatusPaths(status))
8066
9515
  changed.add(file);
8067
9516
  }
9517
+ }
9518
+ catch (_) { }
9519
+ let baselineUnresolved = false;
9520
+ if (lastHead && lastHead !== head) {
9521
+ try {
9522
+ for (const pathspecs of coverageGitPathspecBatches(coverage)) {
9523
+ const diff = child_process.execFileSync("git", [
9524
+ "diff",
9525
+ "--name-only",
9526
+ "-z",
9527
+ lastHead,
9528
+ head,
9529
+ ...(pathspecs.length ? pathspecs : ["--"]),
9530
+ ], {
9531
+ cwd: repoPath,
9532
+ encoding: "utf-8",
9533
+ stdio: ["ignore", "pipe", "ignore"],
9534
+ });
9535
+ for (const file of splitNulPaths(diff))
9536
+ changed.add(file);
9537
+ }
9538
+ }
8068
9539
  catch (_) {
8069
9540
  // `lastHead` is not resolvable in this checkout's object database
8070
9541
  // (a shallow clone missing that commit, or a database seeded from
@@ -8077,7 +9548,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8077
9548
  }
8078
9549
  if (baselineUnresolved) {
8079
9550
  console.warn(`knodin: cached baseline commit ${lastHead} is not resolvable in this checkout (shallow clone or foreign database) — falling back to a full file-drift comparison`);
8080
- canonicalFiles = collectRepoFiles(repoPath);
9551
+ canonicalFiles = collectRepoFiles(repoPath, db);
8081
9552
  if (canonicalFiles.length > 50000) {
8082
9553
  console.warn(`knodin: skipping full-drift fallback reconcile (${canonicalFiles.length} files exceeds 50k bound)`);
8083
9554
  }
@@ -8089,7 +9560,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8089
9560
  }
8090
9561
  else {
8091
9562
  // --- non-git fallback: mtime/size vs index_state ---
8092
- canonicalFiles = collectRepoFiles(repoPath);
9563
+ canonicalFiles = collectRepoFiles(repoPath, db);
8093
9564
  if (canonicalFiles.length > 50000) {
8094
9565
  console.warn(`knodin: skipping freshness reconcile (${canonicalFiles.length} files exceeds 50k bound)`);
8095
9566
  return 0;
@@ -8100,7 +9571,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8100
9571
  // A row in index_state whose file is gone or no longer satisfies the
8101
9572
  // canonical source policy must be purged. Keep those legacy rows in the
8102
9573
  // candidate set without admitting newly changed non-source files.
8103
- canonicalFiles ??= collectRepoFiles(repoPath);
9574
+ canonicalFiles ??= collectRepoFiles(repoPath, db);
8104
9575
  const canonicalPaths = new Set(canonicalFiles);
8105
9576
  const indexedPaths = new Set();
8106
9577
  for (const row of db
@@ -8114,7 +9585,8 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8114
9585
  if (!indexedPaths.has(file))
8115
9586
  changed.add(file);
8116
9587
  const candidates = [...changed]
8117
- .filter((file) => isIndexableSourcePath(file) || indexedPaths.has(file))
9588
+ .filter((file) => (coverageContains(coverage, file) && isIndexableSourcePath(file)) ||
9589
+ indexedPaths.has(file))
8118
9590
  .sort(compareBytes);
8119
9591
  progress?.("indexing-files", 0, `Reconciling ${candidates.length.toLocaleString()} changed files`, {
8120
9592
  phaseTotal: candidates.length,
@@ -8154,6 +9626,10 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8154
9626
  // Re-embed ONLY what changed (indexEmbeddings embeds symbols lacking an
8155
9627
  // embedding), so a no-op reconcile triggers no embedding work at all.
8156
9628
  if (reindexed > 0) {
9629
+ const refreshed = await refreshFlowsForApexFiles(db, repoPath, reindexedPaths);
9630
+ for (const file of refreshed)
9631
+ if (!reindexedPaths.includes(file))
9632
+ reindexedPaths.push(file);
8157
9633
  progress?.("finalizing", 0, skipEmbeddings
8158
9634
  ? "Refreshing identities (semantic embeddings deferred)"
8159
9635
  : "Refreshing identities and semantic embeddings");
@@ -8186,6 +9662,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
8186
9662
  setMeta(db, "knodinVersion", KNODIN_VERSION);
8187
9663
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
8188
9664
  recordFreshnessBaseline(repoPath, db);
9665
+ completeCoverageIntent(db);
8189
9666
  return reindexed;
8190
9667
  }
8191
9668
  catch (e) {
@@ -8309,7 +9786,35 @@ export function resetFreshnessStats() {
8309
9786
  * Returns null when `repoPath` is not a git work tree (or git failed), which
8310
9787
  * sends the caller to the mtime fallback.
8311
9788
  */
8312
- function gitWorkTreeProbe(repoPath) {
9789
+ function gitWorkTreeProbe(repoPath, intent) {
9790
+ if (intent?.mode === "scoped") {
9791
+ try {
9792
+ const options = {
9793
+ cwd: repoPath,
9794
+ encoding: "utf8",
9795
+ stdio: ["ignore", "pipe", "ignore"],
9796
+ maxBuffer: 64 * 1024 * 1024,
9797
+ };
9798
+ const root = child_process
9799
+ .execFileSync("git", ["rev-parse", "--show-toplevel"], options)
9800
+ .trim();
9801
+ if (!isSameDir(root, repoPath))
9802
+ return null;
9803
+ const dirty = new Set();
9804
+ for (const pathspecs of coverageGitPathspecBatches(intent)) {
9805
+ const output = child_process.execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all", ...pathspecs], options);
9806
+ for (const file of parseGitStatusPaths(output))
9807
+ if (coverageContains(intent, file) && isIndexablePath(file))
9808
+ dirty.add(file);
9809
+ }
9810
+ return { root, head: gitHead(repoPath) ?? "", dirty: [...dirty].sort(compareBytes) };
9811
+ }
9812
+ catch (error) {
9813
+ if (error.code === "ENOBUFS")
9814
+ freshnessStats.probeOverflows++;
9815
+ return null;
9816
+ }
9817
+ }
8313
9818
  let lines;
8314
9819
  try {
8315
9820
  lines = child_process
@@ -8385,10 +9890,17 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
8385
9890
  * compare the dirty set against. Uses the existing R8 `meta` table — no schema
8386
9891
  * bump — and self-heals on indexes written before this key existed.
8387
9892
  */
9893
+ function coverageDirtyFingerprint(intent, dirty) {
9894
+ return intent?.mode === "scoped" ? JSON.stringify(dirty) : dirty.join("\n");
9895
+ }
8388
9896
  function recordFreshnessBaseline(repoPath, db) {
8389
- const git = gitWorkTreeProbe(repoPath);
8390
- const dirty = git && isSameDir(git.root, repoPath) ? git.dirty : null;
8391
- setMeta(db, "lastProbeDirty", dirty === null ? "" : dirty.join("\n"));
9897
+ const intent = requireIndexCoverage(coverageForDatabase(db));
9898
+ const git = gitWorkTreeProbe(repoPath, intent);
9899
+ const dirty = git && isSameDir(git.root, repoPath)
9900
+ ? git.dirty.filter((file) => coverageContains(intent, file))
9901
+ : null;
9902
+ setMeta(db, "lastProbeDirty", dirty === null ? "" : coverageDirtyFingerprint(intent, dirty));
9903
+ setMeta(db, "lastProbeCoverage", indexCoverageSummary(coverageForDatabase(db)).digest ?? "");
8392
9904
  }
8393
9905
  /**
8394
9906
  * Decide — WITHOUT indexing anything — whether the index still matches the
@@ -8411,15 +9923,24 @@ function recordFreshnessBaseline(repoPath, db) {
8411
9923
  * the watcher or a cold start, not by this probe.
8412
9924
  */
8413
9925
  function probeDrift(repoPath, db, gitProbe) {
8414
- const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath) : gitProbe;
9926
+ const coverage = coverageForDatabase(db);
9927
+ const intent = requireIndexCoverage(coverage);
9928
+ if (coverage.intentState === "pending" ||
9929
+ (coverage.origin === "explicit" &&
9930
+ getMeta(db, "lastProbeCoverage") !== indexCoverageSummary(coverage).digest))
9931
+ return { drifted: true, verifiable: true };
9932
+ const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath, intent) : gitProbe;
9933
+ if (!git && intent.mode === "scoped" && fs.existsSync(path.join(repoPath, ".git")))
9934
+ return { drifted: false, verifiable: false };
8415
9935
  if (git && isSameDir(git.root, repoPath)) {
8416
9936
  if ((getMeta(db, "lastIndexedHead") ?? "") !== git.head) {
8417
9937
  return { drifted: true, verifiable: true };
8418
9938
  }
8419
- if (getMeta(db, "lastProbeDirty") !== git.dirty.join("\n")) {
9939
+ const dirty = git.dirty.filter((file) => coverageContains(intent, file));
9940
+ if (getMeta(db, "lastProbeDirty") !== coverageDirtyFingerprint(intent, dirty)) {
8420
9941
  return { drifted: true, verifiable: true };
8421
9942
  }
8422
- for (const rel of git.dirty) {
9943
+ for (const rel of dirty) {
8423
9944
  if (!isIndexablePath(rel))
8424
9945
  continue;
8425
9946
  if (fileDriftedFromIndexState(db, repoPath, rel))
@@ -8456,6 +9977,8 @@ function noteFreshness(repoPath, staleness) {
8456
9977
  * after the fact is therefore worthless here.
8457
9978
  */
8458
9979
  async function ensureIndexFresh(repoPath, db) {
9980
+ const coverage = coverageForDatabase(db);
9981
+ requireIndexCoverage(coverage);
8459
9982
  const key = path.resolve(repoPath);
8460
9983
  const cached = freshnessProbes.get(key);
8461
9984
  // A watcher cannot prove that an edit made immediately before this call has
@@ -8475,7 +9998,11 @@ async function ensureIndexFresh(repoPath, db) {
8475
9998
  : watchQueues.get(key)?.ready
8476
9999
  ? WATCHED_FRESHNESS_LEASE_MS
8477
10000
  : FRESHNESS_PROBE_TTL_MS;
8478
- if (cached && Date.now() - cached.at < lease) {
10001
+ if (cached &&
10002
+ Date.now() - cached.at < lease &&
10003
+ coverage.intentState !== "pending" &&
10004
+ (coverage.origin !== "explicit" ||
10005
+ getMeta(db, "lastProbeCoverage") === indexCoverageSummary(coverage).digest)) {
8479
10006
  freshnessStats.cacheHits++;
8480
10007
  return cached.staleness;
8481
10008
  }
@@ -8522,7 +10049,7 @@ function stalenessFor(repoPath) {
8522
10049
  return freshnessProbes.get(path.resolve(repoPath))?.staleness ?? "unknown";
8523
10050
  }
8524
10051
  /** Recursively indexes all matching files within the repository. */
8525
- async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publishProcessState = true,
10052
+ async function indexRepo(repoPath, db, progress, skipEmbeddings = getMeta(db, "semanticDeferred") === "1", publishProcessState = true,
8526
10053
  /**
8527
10054
  * Continue a build already in this database instead of starting over.
8528
10055
  *
@@ -8533,8 +10060,9 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publish
8533
10060
  * observable in that condition.
8534
10061
  */
8535
10062
  resume = false) {
10063
+ setMeta(db, "semanticDeferred", skipEmbeddings ? "1" : "0");
8536
10064
  progress?.("collecting-files", 0, "Discovering indexable files");
8537
- const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath));
10065
+ const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath, db));
8538
10066
  const files = collected.files;
8539
10067
  // Files that reach the indexer but yield nothing can only be observed here,
8540
10068
  // during the parse. Persisted below so `status` can report the gap without
@@ -8582,6 +10110,9 @@ resume = false) {
8582
10110
  db.run("DELETE FROM dependencies;");
8583
10111
  db.run("DELETE FROM mcp_tools;");
8584
10112
  db.run("DELETE FROM index_state;");
10113
+ db.run("DELETE FROM apex_consumer_files;");
10114
+ db.run("DELETE FROM apex_class_uses;");
10115
+ setMeta(db, "apexConsumerFactsRevision", crypto.randomUUID());
8585
10116
  }
8586
10117
  // On a resume, everything already recorded and still matching disk is done.
8587
10118
  // Counted toward progress rather than dropped from it, so the totals stay
@@ -8684,6 +10215,8 @@ resume = false) {
8684
10215
  await pool.run(eligible, async (task, outcome) => {
8685
10216
  switch (outcome.kind) {
8686
10217
  case "result":
10218
+ if (outcome.result.apexClassUseIncompleteReason)
10219
+ tallyOne(unparsedByExtension, extensionBucket(task.relativePath));
8687
10220
  pendingWrites.push(outcome.result);
8688
10221
  break;
8689
10222
  case "unparsed":
@@ -8717,6 +10250,7 @@ resume = false) {
8717
10250
  }
8718
10251
  flushPendingWrites();
8719
10252
  progress?.("finalizing", 0, "Persisting symbol identities and semantic index");
10253
+ await refreshFlowsForApexFiles(db, repoPath, files);
8720
10254
  persistSymbolIdentities(db, repoPath);
8721
10255
  reconcileTypeScriptDi(db, repoPath);
8722
10256
  // Structure is complete at this point; embeddings are the expensive tail. When
@@ -8816,11 +10350,14 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8816
10350
  }
8817
10351
  throw new Error("injected watcher flush failure");
8818
10352
  }
10353
+ const intent = requireIndexCoverage(coverageForDatabase(db));
10354
+ const canonicalScopedFiles = intent.mode === "scoped" ? new Set(collectRepoFiles(resolvedRepoPath, db)) : null;
8819
10355
  for (const relativePath of paths) {
8820
10356
  const absolutePath = path.join(resolvedRepoPath, relativePath);
8821
10357
  // Event order is intentionally ignored: disk truth at flush time handles
8822
10358
  // atomic-save unlink/add sequences and duplicate change notifications.
8823
- if (fs.existsSync(absolutePath)) {
10359
+ if (fs.existsSync(absolutePath) &&
10360
+ (!canonicalScopedFiles || canonicalScopedFiles.has(relativePath))) {
8824
10361
  await indexFile(absolutePath, relativePath, resolvedRepoPath, db);
8825
10362
  recordIndexState(db, resolvedRepoPath, relativePath);
8826
10363
  }
@@ -8828,10 +10365,13 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8828
10365
  purgeFile(relativePath);
8829
10366
  }
8830
10367
  }
8831
- persistSymbolIdentities(db, resolvedRepoPath, paths);
10368
+ const refreshed = await refreshFlowsForApexFiles(db, resolvedRepoPath, paths);
10369
+ persistSymbolIdentities(db, resolvedRepoPath, [...new Set([...paths, ...refreshed])]);
8832
10370
  reconcileTypeScriptDi(db, resolvedRepoPath);
8833
- await indexEmbeddings(db, resolvedRepoPath);
8834
- queue.embeddingReconciliations++;
10371
+ if (getMeta(db, "semanticDeferred") !== "1") {
10372
+ await indexEmbeddings(db, resolvedRepoPath);
10373
+ queue.embeddingReconciliations++;
10374
+ }
8835
10375
  indexGeneration++;
8836
10376
  queue.generationAdvances++;
8837
10377
  recordFreshnessBaseline(resolvedRepoPath, db);
@@ -8871,7 +10411,10 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8871
10411
  if (queue.closed)
8872
10412
  return;
8873
10413
  const relativePath = path.relative(resolvedRepoPath, filePath);
8874
- if (!isIndexableSourcePath(relativePath))
10414
+ const coverage = coverageForDatabase(db);
10415
+ if (!coverage.intent ||
10416
+ !coverageContains(coverage.intent, relativePath) ||
10417
+ !isIndexableSourcePath(relativePath))
8875
10418
  return;
8876
10419
  freshnessProbes.delete(resolvedRepoPath);
8877
10420
  queue.pending.add(relativePath);
@@ -8884,7 +10427,13 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8884
10427
  };
8885
10428
  queue.drain = flush;
8886
10429
  const watcher = chokidar.watch(repoPath, {
8887
- ignored: makeWatchIgnorePredicate(repoPath),
10430
+ ignored: (targetPath) => {
10431
+ const coverage = coverageForDatabase(db);
10432
+ const relative = path.relative(repoPath, targetPath).split(path.sep).join("/");
10433
+ return (!coverage.intent ||
10434
+ !coverageMayContain(coverage.intent, relative) ||
10435
+ makeWatchIgnorePredicate(repoPath)(targetPath));
10436
+ },
8888
10437
  persistent: true,
8889
10438
  ignoreInitial: true,
8890
10439
  });
@@ -9103,6 +10652,10 @@ export async function getOrInitDb(repoPath, options = {}) {
9103
10652
  db.run("ALTER TABLE symbols ADD COLUMN identity TEXT;");
9104
10653
  }
9105
10654
  catch (_) { }
10655
+ try {
10656
+ db.run("ALTER TABLE symbols ADD COLUMN apexDeclaration TEXT;");
10657
+ }
10658
+ catch (_) { }
9106
10659
  db.run("CREATE INDEX IF NOT EXISTS idx_symbols_identity ON symbols(identity);");
9107
10660
  db.run(`
9108
10661
  CREATE TABLE IF NOT EXISTS "references" (
@@ -9153,6 +10706,20 @@ export async function getOrInitDb(repoPath, options = {}) {
9153
10706
  db.run('ALTER TABLE "references" ADD COLUMN calleeIdentity TEXT;');
9154
10707
  }
9155
10708
  catch (_) { }
10709
+ try {
10710
+ db.run('ALTER TABLE "references" ADD COLUMN apexCall TEXT;');
10711
+ }
10712
+ catch (_) { }
10713
+ db.run("CREATE TABLE IF NOT EXISTS apex_file_capabilities(filePath TEXT PRIMARY KEY, version INTEGER NOT NULL)");
10714
+ db.run("CREATE TABLE IF NOT EXISTS apex_consumer_files(filePath TEXT PRIMARY KEY, version INTEGER NOT NULL, sourceHash TEXT NOT NULL, parseState TEXT NOT NULL, isTest INTEGER, facts TEXT NOT NULL, factsHash TEXT)");
10715
+ try {
10716
+ db.run("ALTER TABLE apex_consumer_files ADD COLUMN factsHash TEXT");
10717
+ }
10718
+ catch (_) { }
10719
+ db.run("CREATE TABLE IF NOT EXISTS apex_class_uses(filePath TEXT NOT NULL,startByte INTEGER NOT NULL,endByte INTEGER NOT NULL,kind TEXT NOT NULL,raw TEXT NOT NULL,status TEXT NOT NULL,targetFile TEXT,targetOwner TEXT,targetIdentity TEXT,ownerIdentity TEXT,isTest INTEGER,PRIMARY KEY(filePath,startByte,endByte,kind))");
10720
+ db.run("CREATE INDEX IF NOT EXISTS idx_apex_class_uses_target ON apex_class_uses(targetFile,targetOwner,filePath,startByte,endByte,kind)");
10721
+ db.run("CREATE INDEX IF NOT EXISTS idx_symbols_apex_provenance ON symbols(id) WHERE apexDeclaration IS NOT NULL");
10722
+ db.run('CREATE INDEX IF NOT EXISTS idx_references_apex_provenance ON "references"(id) WHERE apexCall IS NOT NULL');
9156
10723
  db.run(`
9157
10724
  CREATE TABLE IF NOT EXISTS dependencies (
9158
10725
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -9314,12 +10881,16 @@ export async function getOrInitDb(repoPath, options = {}) {
9314
10881
  catch {
9315
10882
  needsIndex = true;
9316
10883
  }
9317
- if (needsIndex && !options.skipContentReconciliation) {
10884
+ if (!options.skipContentReconciliation)
10885
+ requireIndexCoverage(coverageForDatabase(db));
10886
+ if (needsIndex &&
10887
+ coverageForDatabase(db).intent?.mode !== "scoped" &&
10888
+ !options.skipContentReconciliation) {
9318
10889
  await indexRepo(normalizedPath, db, options.onProgress);
9319
10890
  // Just built from the working tree — nothing can be stale yet.
9320
10891
  noteFreshness(normalizedPath, "fresh");
9321
10892
  }
9322
- else if (!needsIndex && !options.skipContentReconciliation) {
10893
+ else if (!options.skipContentReconciliation) {
9323
10894
  // Existing on-disk index: reconcile any offline drift (git pull /
9324
10895
  // branch switch / rebase / external edit) before serving queries.
9325
10896
  const reindexed = await reconcileIndex(normalizedPath, db, options.onProgress);
@@ -9584,7 +11155,11 @@ function listSourceFiles(repoPath, accept) {
9584
11155
  const sealed = sealedRepositories.get(path.resolve(repoPath));
9585
11156
  if (sealed)
9586
11157
  return listSealedFiles(sealed).filter(accept);
9587
- return walkRepoFiles(repoPath, { accept });
11158
+ const db = extractionCoverage.getStore() ?? dbInstances.get(path.resolve(repoPath));
11159
+ const intent = requireIndexCoverage(coverageForDatabase(db));
11160
+ return intent.mode === "full"
11161
+ ? walkRepoFiles(repoPath, { accept })
11162
+ : (gitTrackedCandidates(repoPath, intent) ?? walkCoverageFiles(repoPath, intent)).filter((file) => isIndexablePath(file) && accept(file));
9588
11163
  }
9589
11164
  /** Get verbatim source with line numbers, and say so when it is unavailable. */
9590
11165
  function readSourceRange(repoPath, relativePath, startLine, endLine, maxLines) {
@@ -10040,7 +11615,7 @@ const BUILTIN_CALLEE_NAMES = new Set([
10040
11615
  * unrelated same-named symbol. A caller's own definition lives in its
10041
11616
  * `callerFile`, so recursion stays file-qualified by passing that down.
10042
11617
  */
10043
- function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, visited = new Set(), targetFile = null, targetIdentity = null) {
11618
+ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, visited = new Set(), targetFile = null, targetIdentity = null, includeAnonymousOrigins = false) {
10044
11619
  if (depth <= 0)
10045
11620
  return [];
10046
11621
  const visitedKey = `${currentRepoPath}:${targetSymbol}:${targetFile ?? ""}`;
@@ -10062,28 +11637,32 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
10062
11637
  FROM "references"
10063
11638
  WHERE calleeSymbol = ? AND calleeFile = ?
10064
11639
  AND (? IS NULL OR calleeIdentity = ?)
10065
- AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
11640
+ AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action', 'apex_method')
10066
11641
  `);
10067
11642
  rows = stmt.all(targetSymbol, targetFile, targetIdentity, targetIdentity);
10068
11643
  stmt.finalize();
10069
11644
  }
10070
11645
  else {
10071
11646
  const stmt = db.query(`
10072
- SELECT id, callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind
11647
+ SELECT *
10073
11648
  FROM "references"
10074
- WHERE calleeSymbol = ? AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
11649
+ WHERE calleeSymbol = ? AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action', 'apex_method')
10075
11650
  `);
10076
11651
  rows = stmt.all(targetSymbol);
10077
11652
  stmt.finalize();
10078
11653
  }
11654
+ const validApexReference = apexReferenceReadGuard(db);
10079
11655
  for (const row of rows) {
10080
- // A null callerSymbol means the reference couldn't be attributed to a
10081
- // calling symbol (e.g. a call inside an anonymous callback). Skip it rather
10082
- // than presenting the file path as if it were the caller.
10083
- if (!row.callerSymbol)
11656
+ if (!validApexReference(row))
11657
+ continue;
11658
+ // Preserve exact callee-resolved sites even when no named caller was
11659
+ // extracted. The label is not a symbol identity or a claim about nesting.
11660
+ const anonymous = row.callerSymbol === null;
11661
+ if (anonymous && !includeAnonymousOrigins)
10084
11662
  continue;
10085
11663
  results.push({
10086
- symbol: row.callerSymbol,
11664
+ symbol: row.callerSymbol ?? "(anonymous or top-level)",
11665
+ ...(anonymous ? { callerOrigin: "anonymous-or-top-level" } : {}),
10087
11666
  filePath: row.callerFile,
10088
11667
  lineNumber: row.line,
10089
11668
  repoPath: currentRepoPath,
@@ -10092,6 +11671,8 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
10092
11671
  evidenceFile: row.callerFile,
10093
11672
  evidenceLine: row.line,
10094
11673
  });
11674
+ if (!row.callerSymbol)
11675
+ continue;
10095
11676
  const parentCallers = findCallersFederated(allRepos, currentRepoPath, row.callerSymbol, depth - 1, visited, row.callerFile, row.callerIdentity ?? null);
10096
11677
  results.push(...parentCallers);
10097
11678
  }
@@ -10172,14 +11753,17 @@ function findCalleesFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
10172
11753
  }
10173
11754
  else {
10174
11755
  const stmt = db.query(`
10175
- SELECT id, callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind
11756
+ SELECT *
10176
11757
  FROM "references"
10177
11758
  WHERE callerSymbol = ? AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
10178
11759
  `);
10179
11760
  rows = stmt.all(targetSymbol);
10180
11761
  stmt.finalize();
10181
11762
  }
11763
+ const validApexReference = apexReferenceReadGuard(db);
10182
11764
  for (const row of rows) {
11765
+ if (!validApexReference(row))
11766
+ continue;
10183
11767
  // An unresolved callee (no calleeFile) with a built-in / global name is
10184
11768
  // almost certainly a stdlib member call (Math.min -> "min") — skip it even
10185
11769
  // if some unrelated project symbol happens to share the name, so it doesn't
@@ -11837,6 +13421,7 @@ function withStaleness(engine, claimRepository, openDb, openPolicy) {
11837
13421
  async status(repoPath, options) {
11838
13422
  claimRepository(repoPath);
11839
13423
  const result = await engine.status(repoPath, options);
13424
+ result.indexCoverage ??= result.freshness.indexCoverage;
11840
13425
  result.freshnessMechanism = freshnessMechanismFor(repoPath, openPolicy);
11841
13426
  return result;
11842
13427
  },
@@ -11864,10 +13449,15 @@ function withStaleness(engine, claimRepository, openDb, openPolicy) {
11864
13449
  // stamping them with the index's freshness would misreport them.
11865
13450
  return engine.searchText(term, repoPath, options);
11866
13451
  },
13452
+ queryClassConsumers(repoPath, request) {
13453
+ claimRepository(repoPath);
13454
+ return engine.queryClassConsumers(repoPath, request);
13455
+ },
11867
13456
  async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options) {
11868
13457
  claimRepository(repoPath);
11869
13458
  const result = await engine.query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options);
11870
- result.staleness = stalenessFor(repoPath);
13459
+ if (pattern !== "class_consumers")
13460
+ result.staleness = stalenessFor(repoPath);
11871
13461
  return result;
11872
13462
  },
11873
13463
  async rename(oldName, newName, repoPath, apply, verify, selector) {
@@ -12178,6 +13768,14 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12178
13768
  if (!db)
12179
13769
  throw new Error("candidate database did not open");
12180
13770
  if (!changes) {
13771
+ const oldCoverage = coverageForDatabase(db);
13772
+ writeCoverageIntent(db, resolved, {
13773
+ version: 1,
13774
+ mode: "full",
13775
+ revision: (oldCoverage.intent?.revision ?? 0) + 1,
13776
+ prefixes: [],
13777
+ files: [],
13778
+ });
12181
13779
  // Claim the build before touching anything, and record which tree it
12182
13780
  // is for. A candidate carrying this marker is one a previous run left
12183
13781
  // mid-build; without the commit, a resume could silently continue a
@@ -12190,11 +13788,12 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12190
13788
  // killed above leaves it set, which is exactly what makes the
12191
13789
  // candidate recognisable as resumable rather than abandoned.
12192
13790
  setMeta(db, CLEAN_INDEX_IN_PROGRESS_META_KEY, "");
12193
- return { reconciled: collectRepoFiles(resolved) };
13791
+ completeCoverageIntent(db);
13792
+ return { reconciled: collectRepoFiles(resolved, db) };
12194
13793
  }
12195
13794
  const reconciled = [...new Set(changes)]
12196
13795
  .map((file) => path.relative(resolved, path.resolve(resolved, file)))
12197
- .filter((file) => file !== "" && !file.startsWith("..") && !path.isAbsolute(file))
13796
+ .filter((file) => coverageContains(requireIndexCoverage(coverageForDatabase(db)), file))
12198
13797
  .sort(compareBytes);
12199
13798
  for (const file of reconciled) {
12200
13799
  const absolute = path.join(resolved, file);
@@ -12233,10 +13832,16 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12233
13832
  .filter((file) => !reconciledSet.has(file));
12234
13833
  for (const file of knownPaths)
12235
13834
  recordIndexState(db, resolved, file);
12236
- await refreshFlowsForApexFiles(db, resolved, reconciled);
13835
+ const refreshed = await refreshFlowsForApexFiles(db, resolved, reconciled);
13836
+ for (const file of refreshed) {
13837
+ if (!reconciledSet.has(file))
13838
+ reconciled.push(file);
13839
+ reconciledSet.add(file);
13840
+ }
12237
13841
  persistSymbolIdentities(db, resolved, reconciled);
12238
13842
  reconcileTypeScriptDi(db, resolved);
12239
- await indexEmbeddings(db, resolved);
13843
+ if (getMeta(db, "semanticDeferred") !== "1")
13844
+ await indexEmbeddings(db, resolved);
12240
13845
  setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
12241
13846
  setMeta(db, "knodinVersion", KNODIN_VERSION);
12242
13847
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
@@ -12251,6 +13856,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12251
13856
  const issues = [];
12252
13857
  const db = new Database(candidate.databasePath, { readonly: true });
12253
13858
  try {
13859
+ const coverage = coverageForDatabase(db);
13860
+ if (coverage.origin === "invalid")
13861
+ issues.push("invalid-index-coverage");
13862
+ if (coverage.intentState === "pending")
13863
+ issues.push("pending-index-coverage");
12254
13864
  const quick = db.query("PRAGMA quick_check").all();
12255
13865
  if (quick.length !== 1 || Object.values(quick[0] ?? {})[0] !== "ok")
12256
13866
  issues.push("sqlite-integrity");
@@ -12278,11 +13888,22 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12278
13888
  const symbols = db.query("SELECT COUNT(*) count FROM symbols").get()?.count ?? 0;
12279
13889
  const embeddings = db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
12280
13890
  ?.count ?? 0;
12281
- if (symbols !== embeddings)
13891
+ if (symbols !== embeddings && getMeta(db, "semanticDeferred") !== "1")
12282
13892
  issues.push("semantic-index-incomplete");
13893
+ if (db
13894
+ .query(`SELECT COUNT(*) count FROM symbol_embeddings WHERE ${ORPHANED_EMBEDDING_PREDICATE}`)
13895
+ .get()?.count)
13896
+ issues.push("orphaned-embeddings");
12283
13897
  }
12284
13898
  if (tables.has("index_state")) {
12285
- const sourceFiles = collectRepoFiles(candidate.repo);
13899
+ if (missingApexConsumerDescriptorFiles(db).length)
13900
+ issues.push("apex-consumer-descriptors");
13901
+ if ((db
13902
+ .query("SELECT COUNT(*) count FROM index_state WHERE lower(filePath) LIKE '%.cls' OR lower(filePath) LIKE '%.trigger'")
13903
+ .get()?.count ?? 0) > 0 &&
13904
+ !apexConsumerMaterializationValid(db))
13905
+ issues.push("apex-consumer-materialization");
13906
+ const sourceFiles = collectRepoFiles(candidate.repo, db);
12286
13907
  const sourceSet = new Set(sourceFiles);
12287
13908
  const indexed = db
12288
13909
  .query("SELECT filePath, mtimeMs, size FROM index_state")
@@ -12351,6 +13972,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12351
13972
  async explain(symbol, repoPath, detailLevel = "standard", selector = {}) {
12352
13973
  const repos = await getFederatedRepos(repoPath);
12353
13974
  const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
13975
+ const indexCoverage = allRepos.length === 1
13976
+ ? indexCoverageSummary(coverageForDatabase(allRepos[0].db))
13977
+ : undefined;
12354
13978
  let targetRepoPath = repoPath;
12355
13979
  let primaryDef = null;
12356
13980
  const matches = [];
@@ -12384,6 +14008,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12384
14008
  })),
12385
14009
  };
12386
14010
  return {
14011
+ ...(indexCoverage ? { indexCoverage } : {}),
12387
14012
  symbol,
12388
14013
  summary: ambiguity.message,
12389
14014
  source: "",
@@ -12402,6 +14027,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12402
14027
  }
12403
14028
  if (!primaryDef) {
12404
14029
  return {
14030
+ ...(indexCoverage ? { indexCoverage } : {}),
12405
14031
  symbol,
12406
14032
  summary: `Symbol "${symbol}" not found in codebase.`,
12407
14033
  source: "",
@@ -12508,6 +14134,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12508
14134
  allBlastFiles.length > cap ||
12509
14135
  omissions.length > cap;
12510
14136
  return {
14137
+ ...(indexCoverage ? { indexCoverage } : {}),
12511
14138
  identity: symbolIdentity(targetRepoPath, primaryDef),
12512
14139
  symbol,
12513
14140
  summary,
@@ -13192,9 +14819,41 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13192
14819
  const db = activeDb ??
13193
14820
  (fs.existsSync(diskDbPath) ? new Database(diskDbPath, { readonly: true }) : null);
13194
14821
  const verifiedAt = new Date().toISOString();
14822
+ if (db && coverageForDatabase(db).origin === "invalid") {
14823
+ const result = {
14824
+ status: "repair-needed",
14825
+ repo: resolved,
14826
+ version: KNODIN_VERSION,
14827
+ schemaVersion: 0,
14828
+ model: { id: "Xenova/all-MiniLM-L6-v2", dimensions: 384, locality: "local" },
14829
+ indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
14830
+ coverage: {
14831
+ sourceFiles: 0,
14832
+ indexedFiles: 0,
14833
+ filesWithSymbols: 0,
14834
+ percent: 0,
14835
+ countsUnknown: true,
14836
+ },
14837
+ orphaned: { embeddings: 0, references: 0, dependencies: 0 },
14838
+ missing: {
14839
+ files: [],
14840
+ records: ["malformed index coverage intent; inventory was not enumerated"],
14841
+ },
14842
+ lastSuccessfulReconciliation: null,
14843
+ freshness: buildFreshnessEnvelope(resolved, db, verifiedAt, "repair-needed", null),
14844
+ verification: { mode: "deep-audit", verifiedAt },
14845
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
14846
+ repairSteps: [
14847
+ "Restore valid coverage metadata, or explicitly rebuild a full candidate with knodin index --clean. Automatic repair cannot guess the intended scope.",
14848
+ ],
14849
+ };
14850
+ if (!activeDb)
14851
+ db.close();
14852
+ return result;
14853
+ }
13195
14854
  const activity = options.ignoreActiveOperation ? null : readIndexActivity(resolved);
13196
14855
  if (activity) {
13197
- const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
14856
+ const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved, db));
13198
14857
  const sourceFiles = collected.files;
13199
14858
  const skipped = buildCoverageSkips(collected.skippedByExtension, db);
13200
14859
  let schemaVersion = 0;
@@ -13322,7 +14981,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13322
14981
  const persisted = readPersistedStatusAudit(resolved, diskDbPath, databaseFingerprint, schemaVersion);
13323
14982
  if (persisted) {
13324
14983
  throwIfAborted(options.signal);
13325
- const gitProbe = measurePerfPhaseSync("freshness_probe", () => gitWorkTreeProbe(resolved));
14984
+ const gitProbe = measurePerfPhaseSync("freshness_probe", () => gitWorkTreeProbe(resolved, requireIndexCoverage(coverageForDatabase(db))));
13326
14985
  const drift = probeDrift(resolved, db, gitProbe);
13327
14986
  const probeVerifiedAt = new Date().toISOString();
13328
14987
  const freshness = buildFreshnessEnvelope(resolved, db, probeVerifiedAt, drift.verifiable ? (drift.drifted ? "stale-working-tree" : undefined) : "unknown", gitProbe);
@@ -13400,8 +15059,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13400
15059
  }
13401
15060
  throwIfAborted(options.signal);
13402
15061
  const collected = options.signal
13403
- ? await measurePerfPhase("status_audit", () => collectRepoFilesWithCoverageAbortable(resolved, options.signal))
13404
- : measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
15062
+ ? await measurePerfPhase("status_audit", () => collectRepoFilesWithCoverageAbortable(resolved, options.signal, db))
15063
+ : measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved, db));
13405
15064
  throwIfAborted(options.signal);
13406
15065
  const sourceFiles = collected.files;
13407
15066
  const coverageSkips = buildCoverageSkips(collected.skippedByExtension, db);
@@ -13498,8 +15157,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13498
15157
  await yieldToIndexEventLoop();
13499
15158
  }
13500
15159
  const row = indexedRows[index];
13501
- const exists = fs.existsSync(path.join(resolved, row.filePath));
13502
15160
  if (!sourceFileSet.has(row.filePath)) {
15161
+ const exists = fs.existsSync(path.join(resolved, row.filePath));
13503
15162
  (exists ? ineligibleFiles : staleFiles).push(row.filePath);
13504
15163
  continue;
13505
15164
  }
@@ -13523,6 +15182,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13523
15182
  AND NOT (d.fromFile LIKE '.knodin/dbt/%' AND EXISTS (SELECT 1 FROM symbols s WHERE s.filePath=d.fromFile))`),
13524
15183
  };
13525
15184
  const records = staleFiles.map((file) => `indexed file no longer exists: ${file}`);
15185
+ for (const file of missingApexConsumerDescriptorFiles(db))
15186
+ records.push(`Apex class-consumer descriptor missing or invalid: ${file}`);
15187
+ if (indexedRows.some((row) => /\.(cls|trigger)$/i.test(row.filePath)) &&
15188
+ !apexConsumerMaterializationValid(db))
15189
+ records.push("Apex class-consumer binding materialization is incomplete");
13526
15190
  for (const file of ineligibleFiles)
13527
15191
  records.push(`indexed file is no longer eligible: ${file}`);
13528
15192
  for (const file of damagedFiles)
@@ -13535,10 +15199,13 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13535
15199
  records.push(`${orphaned.dependencies} dependency row(s) lack an indexed source file`);
13536
15200
  if (getMeta(db, "repairPostprocessingPending") === "1")
13537
15201
  records.push("repair post-processing is incomplete");
15202
+ if (coverageForDatabase(db).intentState === "pending")
15203
+ records.push("index coverage operation is pending");
13538
15204
  const needsRepair = missingFiles.length > 0 || records.length > 0;
13539
15205
  const freshness = buildFreshnessEnvelope(resolved, db, verifiedAt, needsRepair ? "repair-needed" : undefined);
13540
15206
  const stale = !needsRepair && freshness.state !== "fresh";
13541
15207
  const result = {
15208
+ indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
13542
15209
  status: needsRepair ? "repair-needed" : stale ? "stale" : "healthy",
13543
15210
  repo: resolved,
13544
15211
  version: KNODIN_VERSION,
@@ -13585,7 +15252,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13585
15252
  };
13586
15253
  if (!needsRepair && !stale)
13587
15254
  noteFreshness(resolved, "fresh");
13588
- if (activeDb) {
15255
+ if (activeDb && options.checkpoint !== "defer") {
13589
15256
  try {
13590
15257
  db.run("PRAGMA wal_checkpoint(PASSIVE);");
13591
15258
  }
@@ -13726,12 +15393,19 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13726
15393
  }
13727
15394
  emitProgress("planning", 0, "Building the repair plan");
13728
15395
  const repairKinds = new Map();
15396
+ const descriptorRepairPaths = new Set();
13729
15397
  for (const file of before.missing.files)
13730
15398
  repairKinds.set(file, "indexed");
13731
15399
  for (const record of before.missing.records) {
13732
15400
  const damagedPrefix = "indexed snapshot differs from disk: ";
13733
15401
  const stalePrefix = "indexed file no longer exists: ";
13734
15402
  const ineligiblePrefix = "indexed file is no longer eligible: ";
15403
+ const descriptorPrefix = "Apex class-consumer descriptor missing or invalid: ";
15404
+ if (record.startsWith(descriptorPrefix)) {
15405
+ const file = record.slice(descriptorPrefix.length);
15406
+ descriptorRepairPaths.add(file);
15407
+ repairKinds.set(file, "damaged");
15408
+ }
13735
15409
  if (record.startsWith(damagedPrefix))
13736
15410
  repairKinds.set(record.slice(damagedPrefix.length), "damaged");
13737
15411
  else if (record.startsWith(stalePrefix))
@@ -13779,9 +15453,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13779
15453
  if (schemaPrepared)
13780
15454
  committedWork = true;
13781
15455
  const inheritedPostprocessing = getMeta(db, "repairPostprocessingPending") === "1";
13782
- const eligibleRepairPaths = new Set(collectRepoFiles(resolved));
15456
+ const eligibleRepairPaths = new Set(collectRepoFiles(resolved, db));
13783
15457
  repairPaths = [...repairKinds.keys()]
13784
- .filter((file) => repairKinds.get(file) === "stale" || fileDriftedFromIndexState(db, resolved, file))
15458
+ .filter((file) => repairKinds.get(file) === "stale" ||
15459
+ descriptorRepairPaths.has(file) ||
15460
+ fileDriftedFromIndexState(db, resolved, file))
13785
15461
  .sort(compareBytes);
13786
15462
  const repairAffectsTypeScriptDi = inheritedPostprocessing || repairPathsAffectTypeScriptDi(db, resolved, repairPaths);
13787
15463
  counts.skipped = Math.max(0, repairKinds.size - repairPaths.length);
@@ -13853,17 +15529,31 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13853
15529
  });
13854
15530
  cancelBoundary();
13855
15531
  }
13856
- const needsPostprocessing = repairPaths.length > 0 || getMeta(db, "repairPostprocessingPending") === "1";
15532
+ const needsPostprocessing = repairPaths.length > 0 ||
15533
+ before.missing.records.includes("Apex class-consumer binding materialization is incomplete") ||
15534
+ getMeta(db, "repairPostprocessingPending") === "1" ||
15535
+ coverageForDatabase(db).intentState === "pending";
13857
15536
  if (needsPostprocessing) {
13858
15537
  emitProgress("symbol-identities", 0, "Persisting stable symbol identities", {
13859
15538
  phaseTotal: 1,
13860
15539
  });
13861
15540
  cancelBoundary();
13862
- const identityScope = inheritedPostprocessing ? undefined : repairPaths;
15541
+ const bindingPaths = inheritedPostprocessing
15542
+ ? db
15543
+ .query("SELECT filePath FROM index_state WHERE filePath LIKE '%.cls' OR filePath LIKE '%.cmp' OR filePath LIKE 'force-app/main/default/lwc/%.js' OR filePath LIKE 'force-app/main/default/aura/%.app' OR filePath LIKE 'force-app/main/default/pages/%.page' UNION SELECT toFile AS filePath FROM dependencies WHERE toFile LIKE '%.cls' OR toFile LIKE '%.cmp' OR toFile LIKE 'force-app/main/default/lwc/%.js' OR toFile LIKE 'force-app/main/default/aura/%.app' OR toFile LIKE 'force-app/main/default/pages/%.page'")
15544
+ .all()
15545
+ .map((row) => row.filePath)
15546
+ : repairPaths;
15547
+ const refreshed = await refreshFlowsForApexFiles(db, resolved, bindingPaths);
15548
+ const identityScope = inheritedPostprocessing
15549
+ ? undefined
15550
+ : [...new Set([...repairPaths, ...refreshed])];
13863
15551
  if (options?.signal)
13864
15552
  await persistSymbolIdentitiesCancellable(db, resolved, identityScope, activeOperation.controller.signal);
13865
15553
  else
13866
15554
  persistSymbolIdentities(db, resolved, identityScope);
15555
+ if (before.missing.records.includes("Apex class-consumer binding materialization is incomplete"))
15556
+ persistApexClassConsumerEndpoints(db);
13867
15557
  if (repairAffectsTypeScriptDi)
13868
15558
  reconcileTypeScriptDi(db, resolved);
13869
15559
  committedWork = true;
@@ -13958,11 +15648,12 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13958
15648
  // revision; a second deep audit proves the complete envelope.
13959
15649
  const contentVerified = after.coverage.sourceFiles === after.coverage.indexedFiles &&
13960
15650
  after.missing.files.length === 0 &&
13961
- after.missing.records.length === 0 &&
15651
+ after.missing.records.every((record) => record === "index coverage operation is pending") &&
13962
15652
  Object.values(after.orphaned).every((count) => count === 0);
13963
15653
  if (contentVerified) {
13964
15654
  db.run("BEGIN TRANSACTION;");
13965
15655
  try {
15656
+ markCoverageIntentComplete(db);
13966
15657
  if (after.freshness.state === "stale-head" || after.freshness.state === "unknown") {
13967
15658
  setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
13968
15659
  }
@@ -14067,26 +15758,60 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14067
15758
  },
14068
15759
  async index(repoPath, files, clean = false, options) {
14069
15760
  const resolved = path.resolve(repoPath);
14070
- // Expand `--under` before anything else, so everything downstream sees an
14071
- // ordinary explicit-file index and no path can route a scoped rebuild
14072
- // into the destructive full-index branch.
14073
15761
  const scopedUnder = options?.under;
14074
- if (scopedUnder !== undefined && (!files || files.length === 0)) {
14075
- const prefix = path
14076
- .relative(resolved, path.resolve(resolved, scopedUnder))
15762
+ const normalizeSelector = (file, allowRoot = false) => {
15763
+ const relative = path
15764
+ .relative(resolved, path.resolve(resolved, file))
14077
15765
  .split(path.sep)
14078
15766
  .join("/");
14079
- if (prefix.startsWith("..") || path.isAbsolute(prefix))
14080
- throw new Error(`knodin index --under: ${scopedUnder} is outside the repository`);
14081
- const collected = collectRepoFilesWithCoverage(resolved);
14082
- files =
14083
- prefix === ""
14084
- ? collected.files
14085
- : collected.files.filter((file) => file === prefix || file.startsWith(`${prefix}/`));
14086
- // Failing loudly rather than indexing nothing and reporting success:
14087
- // a silent no-op here would look identical to a completed rebuild.
14088
- if (files.length === 0)
14089
- throw new Error(`knodin index --under: no indexable files under ${scopedUnder || "the repository root"}`);
15767
+ if (!(allowRoot && relative === "") &&
15768
+ (!canonicalCoveragePath(relative) || !isIndexablePath(relative)))
15769
+ throw new Error(`knodin index: selector is invalid or outside the repository: ${file}`);
15770
+ if (relative &&
15771
+ fs.existsSync(path.join(resolved, relative)) &&
15772
+ !safeCoveragePath(resolved, relative))
15773
+ throw new Error(`knodin index: selector follows a symbolic link: ${file}`);
15774
+ return relative;
15775
+ };
15776
+ const prefix = scopedUnder === undefined ? undefined : normalizeSelector(scopedUnder, true);
15777
+ files = files?.length
15778
+ ? [...new Set(files.map((file) => normalizeSelector(file)))]
15779
+ : undefined;
15780
+ // Manifests are resolution inputs, not source coverage selectors. A
15781
+ // config-only request must never fall through to a full repository index.
15782
+ const packageInputs = (files ?? []).filter((file) => path.posix.basename(file) === "package.json" &&
15783
+ !safeCoveragePath(resolved, file)?.isDirectory());
15784
+ if (packageInputs.length) {
15785
+ const packageInputSet = new Set(packageInputs);
15786
+ files = files?.filter((file) => !packageInputSet.has(file));
15787
+ if (!files?.length && prefix === undefined)
15788
+ throw new Error("knodin index: configuration inputs require source files or --under; no source work requested");
15789
+ }
15790
+ for (const file of files ?? [])
15791
+ if (!isIndexableSourcePath(file) || safeCoveragePath(resolved, file)?.isDirectory())
15792
+ throw new Error(`knodin index: explicit file selector must name an indexable file: ${file}`);
15793
+ const requestedCoverage = prefix === ""
15794
+ ? null
15795
+ : prefix !== undefined
15796
+ ? { prefixes: [prefix], files: files ?? [] }
15797
+ : files
15798
+ ? { prefixes: [], files }
15799
+ : null;
15800
+ let promoteScope = false;
15801
+ if (requestedCoverage === null && !openPolicy.databasePath) {
15802
+ const active = dbInstances.get(resolved);
15803
+ const disk = resolveDbPath(resolved);
15804
+ const prior = active ?? (fs.existsSync(disk) ? new Database(disk, { readonly: true }) : null);
15805
+ try {
15806
+ if (prior) {
15807
+ const state = coverageForDatabase(prior);
15808
+ promoteScope = state.intent?.mode === "scoped" || state.origin === "invalid";
15809
+ }
15810
+ }
15811
+ finally {
15812
+ if (prior && prior !== active)
15813
+ prior.close();
15814
+ }
14090
15815
  }
14091
15816
  const indexCoordination = acquireLifecycleCoordination({
14092
15817
  command: "index",
@@ -14094,8 +15819,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14094
15819
  verificationToken: process.env.KNODIN_UPDATE_VERIFICATION_TOKEN,
14095
15820
  });
14096
15821
  try {
14097
- if (clean &&
14098
- (!files || files.length === 0) &&
15822
+ if (requestedCoverage === null &&
15823
+ (clean || promoteScope) &&
14099
15824
  !openPolicy.databasePath &&
14100
15825
  !process.env.VITEST &&
14101
15826
  process.env.NODE_ENV !== "test") {
@@ -14153,6 +15878,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14153
15878
  return {
14154
15879
  indexed: reconciliation.reconciled,
14155
15880
  unchanged: [],
15881
+ semanticReadiness: health.semanticReadiness,
15882
+ indexCoverage: health.indexCoverage,
14156
15883
  verification: {
14157
15884
  status: health.status,
14158
15885
  issueCount,
@@ -14192,6 +15919,28 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14192
15919
  skipContentReconciliation: true,
14193
15920
  skipFreshnessGuard: true,
14194
15921
  });
15922
+ const existingCoverage = coverageForDatabase(db);
15923
+ const freshCoverage = existingCoverage.origin === "legacy" &&
15924
+ !getMeta(db, "lastSuccessfulReconciliation") &&
15925
+ (db.query("SELECT COUNT(*) count FROM index_state").get()
15926
+ ?.count ?? 0) === 0;
15927
+ const intent = mergeIndexCoverage(existingCoverage, requestedCoverage, freshCoverage);
15928
+ writeCoverageIntent(db, resolved, intent);
15929
+ // Persist the requested semantic policy even if collection fails or
15930
+ // the prefix is currently empty; recovery must not start inference.
15931
+ setMeta(db, "semanticDeferred", options?.skipEmbeddings === true ? "1" : "0");
15932
+ if (prefix !== undefined && prefix !== "") {
15933
+ const selection = {
15934
+ version: 1,
15935
+ mode: "scoped",
15936
+ revision: intent.revision,
15937
+ prefixes: [prefix],
15938
+ files: files ?? [],
15939
+ };
15940
+ files = collectRepoFilesWithCoverage(resolved, db, selection).files;
15941
+ if (files.length === 0)
15942
+ throw new Error(`knodin index --under: no indexable files under ${scopedUnder}`);
15943
+ }
14195
15944
  let completionMessage = "Local graph is current";
14196
15945
  if (files && files.length > 0) {
14197
15946
  progress("indexing-files", 0, `Checking ${files.length.toLocaleString()} requested files`, {
@@ -14205,7 +15954,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14205
15954
  unchanged.push(relativePath);
14206
15955
  }
14207
15956
  else if (fs.existsSync(absPath)) {
14208
- await indexFile(absPath, relativePath, repoPath, db);
15957
+ await explicitPackageInputs.run(packageInputs, () => indexFile(absPath, relativePath, repoPath, db));
14209
15958
  recordIndexState(db, repoPath, relativePath);
14210
15959
  }
14211
15960
  else if (shouldPurgeMissingFile(db)) {
@@ -14236,9 +15985,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14236
15985
  progress("finalizing", 0, "Refreshing identities and semantic embeddings");
14237
15986
  persistSymbolIdentities(db, repoPath, indexed);
14238
15987
  reconcileTypeScriptDi(db, repoPath);
14239
- await indexEmbeddings(db, repoPath, progress);
14240
15988
  indexGeneration++;
14241
15989
  }
15990
+ if (!options?.skipEmbeddings)
15991
+ await indexEmbeddings(db, repoPath, progress);
14242
15992
  const alreadyCurrent = unchanged.length === 1
14243
15993
  ? "1 requested file already current"
14244
15994
  : `${unchanged.length.toLocaleString()} requested files already current`;
@@ -14336,10 +16086,13 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14336
16086
  });
14337
16087
  const contentVerified = health.coverage.sourceFiles === health.coverage.indexedFiles &&
14338
16088
  health.missing.files.length === 0 &&
14339
- health.missing.records.length === 0 &&
16089
+ health.missing.records.every((record) => record === "index coverage operation is pending") &&
14340
16090
  Object.values(health.orphaned).every((count) => count === 0);
14341
16091
  if (contentVerified &&
14342
- (health.freshness.state === "stale-head" || health.freshness.state === "unknown")) {
16092
+ (coverageForDatabase(db).intentState === "pending" ||
16093
+ health.freshness.state === "stale-head" ||
16094
+ health.freshness.state === "unknown")) {
16095
+ completeCoverageIntent(db);
14343
16096
  setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
14344
16097
  setMeta(db, "knodinVersion", KNODIN_VERSION);
14345
16098
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
@@ -14394,6 +16147,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14394
16147
  return {
14395
16148
  indexed,
14396
16149
  unchanged,
16150
+ indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
14397
16151
  // Measured from the graph rather than inferred from the flag: a
14398
16152
  // run that deferred embeddings and a run that never had any are
14399
16153
  // the same state to a consumer, and a partially embedded graph is
@@ -14488,6 +16242,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14488
16242
  };
14489
16243
  const formatPath = makePathFormatter(resolvedRepoPath);
14490
16244
  const retrievalMode = options.retrievalMode ?? "complete";
16245
+ const hasSearchVectors = [...searchSnapshots.values()].some((snapshot) => snapshot.rows.some((row) => row.vector !== null && accepts(row)));
14491
16246
  const normalize = (value) => value.toLocaleLowerCase().replace(/[^a-z0-9]/g, "");
14492
16247
  const hydrateStructural = (matches) => {
14493
16248
  const fileToCommunityMap = matches.length > 0
@@ -14733,7 +16488,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14733
16488
  [...lexicalRanks.values()].some((ranks) => ranks.and.length > 0);
14734
16489
  if (retrievalMode === "lexical-only" ||
14735
16490
  retrievalMode === "bounded-graph" ||
14736
- structurallySufficient) {
16491
+ structurallySufficient ||
16492
+ !hasSearchVectors) {
14737
16493
  const matches = allRepos.flatMap((repo) => {
14738
16494
  const snapshot = searchSnapshots.get(repo.path);
14739
16495
  const ranks = lexicalRanks.get(repo.path);
@@ -14764,6 +16520,19 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14764
16520
  };
14765
16521
  }
14766
16522
  }
16523
+ // No query vector can retrieve candidates from an empty vector corpus.
16524
+ // Explicit vector-only requests must not trigger model loading either.
16525
+ if (!hasSearchVectors) {
16526
+ return {
16527
+ results: [],
16528
+ total: 0,
16529
+ offset,
16530
+ limit: maxResults,
16531
+ hasMore: false,
16532
+ retrieval: { route: [], embeddingsUsed: false },
16533
+ semanticReadiness,
16534
+ };
16535
+ }
14767
16536
  // 1. Generate query embedding once
14768
16537
  const queryVec = await generateMeasuredEmbedding(query, true);
14769
16538
  const rankedCandidates = [];
@@ -14988,7 +16757,59 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
14988
16757
  semanticReadiness,
14989
16758
  };
14990
16759
  },
16760
+ async queryClassConsumers(repoPath, request) {
16761
+ const resolved = path.resolve(repoPath);
16762
+ const db = await openDb(resolved);
16763
+ return readClassConsumerPage(db, resolved, request);
16764
+ },
14991
16765
  async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector = {}, impactOptions = {}, options = {}) {
16766
+ if (pattern === "class_consumers") {
16767
+ if (to !== undefined ||
16768
+ depth !== undefined ||
16769
+ options.kinds ||
16770
+ options.direction ||
16771
+ options.relationKinds ||
16772
+ options.sort ||
16773
+ options.topN ||
16774
+ options.base ||
16775
+ options.minLines !== undefined ||
16776
+ options.minComplexity !== undefined)
16777
+ throw new Error("class_consumers supports only target selectors, testScope/path, limit and typed continuation budgets");
16778
+ const secret = evidenceHandleSecret(path.resolve(repoPath)).secret;
16779
+ const cursor = options.classConsumerContinuation
16780
+ ? readClassConsumerCursor(options.classConsumerContinuation, secret)
16781
+ : undefined;
16782
+ const request = {
16783
+ symbol: target,
16784
+ selector,
16785
+ testScope: options.testScope ?? "all",
16786
+ path: options.path,
16787
+ limit: limit ?? 100,
16788
+ ...(cursor
16789
+ ? {
16790
+ after: cursor.after,
16791
+ expectedQueryDigest: cursor.queryDigest,
16792
+ expectedSnapshotDigest: cursor.snapshotDigest,
16793
+ }
16794
+ : {}),
16795
+ };
16796
+ const page = await this.queryClassConsumers(repoPath, request);
16797
+ const envelope = finalizeClassConsumerPage(page, {
16798
+ secret,
16799
+ budget: {
16800
+ bytes: options.classConsumerMaxBytes,
16801
+ tokens: options.classConsumerMaxTokens,
16802
+ items: options.classConsumerMaxItems,
16803
+ },
16804
+ decorate: (candidate) => ({
16805
+ ...candidate,
16806
+ target,
16807
+ targetResolution: "resolved",
16808
+ staleness: stalenessFor(repoPath),
16809
+ }),
16810
+ });
16811
+ return { ...envelope, pattern: "class_consumers", target, targetResolution: "resolved" };
16812
+ }
14992
16813
  if (pattern === "lsp_diagnostics" ||
14993
16814
  pattern === "lsp_definitions" ||
14994
16815
  pattern === "lsp_declarations" ||
@@ -15091,6 +16912,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15091
16912
  const resolved = resolveSymbolRows(db, primary.path, target, selector);
15092
16913
  if (resolved.ambiguity)
15093
16914
  return {
16915
+ indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
15094
16916
  pattern,
15095
16917
  target,
15096
16918
  ...(to ? { to } : {}),
@@ -15139,7 +16961,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15139
16961
  }
15140
16962
  const finish = (rows, extra = {}) => {
15141
16963
  for (const row of rows) {
15142
- if (row.identity || !row.symbol || !row.file)
16964
+ if (row.identity || row.callerOrigin || !row.symbol || !row.file)
15143
16965
  continue;
15144
16966
  const definition = db
15145
16967
  .query("SELECT * FROM symbols WHERE name = ? AND filePath = ? LIMIT 1")
@@ -15148,6 +16970,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15148
16970
  row.identity = symbolIdentity(primary.path, definition);
15149
16971
  }
15150
16972
  return {
16973
+ indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
15151
16974
  pattern,
15152
16975
  target,
15153
16976
  ...(to ? { to } : {}),
@@ -15155,6 +16978,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15155
16978
  results: rows.slice(0, cap),
15156
16979
  ...(rows.length > cap ? { hasMore: true } : {}),
15157
16980
  ...(targetResolution ? { targetResolution } : {}),
16981
+ ...((pattern === "callers_of" || pattern === "callees_of") &&
16982
+ selectedTarget &&
16983
+ /\.(?:cls|trigger)$/i.test(selectedTarget.filePath)
16984
+ ? { apexReceiverCapability: apexReceiverCapability(db) }
16985
+ : {}),
15158
16986
  ...extra,
15159
16987
  };
15160
16988
  };
@@ -15335,7 +17163,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15335
17163
  }
15336
17164
  }
15337
17165
  const refs = pattern === "callers_of"
15338
- ? findCallersFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null)
17166
+ ? findCallersFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null, true)
15339
17167
  : findCalleesFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null);
15340
17168
  // One row per caller symbol, but carrying every call site it has.
15341
17169
  // Collapsing to the first line lost the rest and made `count`
@@ -15343,25 +17171,44 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15343
17171
  const byCaller = new Map();
15344
17172
  const rows = [];
15345
17173
  let callSiteCount = 0;
17174
+ let bindingSiteCount = 0;
15346
17175
  for (const r of refs) {
15347
17176
  const file = formatPath(r.repoPath || defRepo, r.filePath);
15348
- const key = `${r.symbol}|${file}`;
15349
- callSiteCount++;
17177
+ const binding = r.kind === "apex_method";
17178
+ const key = `${r.symbol}|${file}|${binding ? "binding" : "call"}`;
17179
+ if (binding)
17180
+ bindingSiteCount++;
17181
+ else
17182
+ callSiteCount++;
15350
17183
  const existing = byCaller.get(key);
15351
17184
  if (existing) {
15352
- if (r.lineNumber !== undefined && !existing.callSiteLines?.includes(r.lineNumber))
15353
- existing.callSiteLines?.push(r.lineNumber);
17185
+ const lines = binding ? existing.bindingSiteLines : existing.callSiteLines;
17186
+ if (r.lineNumber !== undefined && !lines?.includes(r.lineNumber))
17187
+ lines?.push(r.lineNumber);
15354
17188
  continue;
15355
17189
  }
15356
- const rr = (r.repoPath ? allRepos.find((x) => x.path === r.repoPath) : primary)?.db
15357
- .query("SELECT * FROM symbols WHERE name = ? AND filePath = ? LIMIT 1")
15358
- .get(r.symbol, r.filePath);
17190
+ const rr = r.callerOrigin
17191
+ ? undefined
17192
+ : (r.repoPath ? allRepos.find((x) => x.path === r.repoPath) : primary)?.db
17193
+ .query("SELECT * FROM symbols WHERE name = ? AND filePath = ? LIMIT 1")
17194
+ .get(r.symbol, r.filePath);
15359
17195
  const row = {
15360
17196
  symbol: r.symbol,
17197
+ ...(r.callerOrigin
17198
+ ? {
17199
+ callerOrigin: r.callerOrigin,
17200
+ sourceEvidence: "Resolved call site; no named enclosing caller was extracted. Anonymous nesting is not established.",
17201
+ }
17202
+ : {}),
15361
17203
  file,
15362
17204
  line: r.lineNumber,
15363
17205
  kind: r.kind ?? "call",
15364
- callSiteLines: r.lineNumber !== undefined ? [r.lineNumber] : [],
17206
+ ...(binding
17207
+ ? {
17208
+ bindingSiteLines: r.lineNumber !== undefined ? [r.lineNumber] : [],
17209
+ sourceEvidence: "Static Apex import binding; not evidence that the method executes.",
17210
+ }
17211
+ : { callSiteLines: r.lineNumber !== undefined ? [r.lineNumber] : [] }),
15365
17212
  ...(rr ? { identity: symbolIdentity(r.repoPath || defRepo, rr) } : {}),
15366
17213
  };
15367
17214
  byCaller.set(key, row);
@@ -15369,9 +17216,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15369
17216
  }
15370
17217
  for (const row of rows) {
15371
17218
  row.callSiteLines?.sort((a, b) => a - b);
15372
- row.line = row.callSiteLines?.[0] ?? row.line;
17219
+ row.bindingSiteLines?.sort((a, b) => a - b);
17220
+ row.line = row.callSiteLines?.[0] ?? row.bindingSiteLines?.[0] ?? row.line;
15373
17221
  }
15374
- return finish(rows, { callSiteCount });
17222
+ return finish(rows, { callSiteCount, ...(bindingSiteCount ? { bindingSiteCount } : {}) });
15375
17223
  }
15376
17224
  case "imports_of": {
15377
17225
  const stmt = db.query("SELECT DISTINCT toFile, kind FROM dependencies WHERE fromFile = ?");
@@ -15456,16 +17304,188 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15456
17304
  });
15457
17305
  }
15458
17306
  case "inheritors_of": {
15459
- // Classes that extend or implement the target (kind-tagged references).
15460
- const stmt = db.query("SELECT DISTINCT callerSymbol, callerFile, kind, line FROM \"references\" WHERE calleeSymbol = ? AND kind IN ('extends', 'implements') AND callerSymbol IS NOT NULL");
15461
- const rows = stmt.all(selectedTarget?.name ?? target).map((r) => ({
15462
- symbol: r.callerSymbol,
15463
- file: r.callerFile,
15464
- kind: r.kind,
15465
- line: r.line,
15466
- }));
15467
- stmt.finalize();
15468
- return finish(rows);
17307
+ db.run("SAVEPOINT apex_heritage_read");
17308
+ try {
17309
+ // Unselected heritage queries historically aggregate names across languages.
17310
+ // An Apex spelling match must not suppress those independent legacy rows.
17311
+ const aggregate = !selector.file && !selector.identity;
17312
+ const nonApexRows = db
17313
+ .query(`SELECT DISTINCT callerSymbol, callerFile, kind, line FROM "references"
17314
+ WHERE calleeSymbol = ? AND kind IN ('extends', 'implements')
17315
+ AND callerSymbol IS NOT NULL AND lower(callerFile) NOT LIKE '%.cls'
17316
+ AND lower(callerFile) NOT LIKE '%.trigger'
17317
+ ORDER BY callerFile COLLATE BINARY, line, kind COLLATE BINARY, callerSymbol COLLATE BINARY LIMIT ?`)
17318
+ .all(selectedTarget?.name ?? target, cap + 1)
17319
+ .map((row) => ({
17320
+ symbol: row.callerSymbol,
17321
+ file: row.callerFile,
17322
+ kind: row.kind,
17323
+ line: row.line,
17324
+ }));
17325
+ const finishHeritage = (apexRows, apex, partial, extra = {}) => {
17326
+ const includeNonApex = aggregate || apex === "not-selected";
17327
+ const merged = [...(includeNonApex ? nonApexRows : []), ...apexRows];
17328
+ const unique = [
17329
+ ...new Map(merged.map((row) => [
17330
+ JSON.stringify([row.file, row.line, row.column ?? null, row.kind, row.symbol]),
17331
+ row,
17332
+ ])).values(),
17333
+ ];
17334
+ unique.sort((a, b) => Buffer.compare(Buffer.from(a.file ?? ""), Buffer.from(b.file ?? "")) ||
17335
+ (a.line ?? 0) - (b.line ?? 0) ||
17336
+ (a.column ?? 0) - (b.column ?? 0) ||
17337
+ Buffer.compare(Buffer.from(a.kind ?? ""), Buffer.from(b.kind ?? "")) ||
17338
+ Buffer.compare(Buffer.from(a.symbol ?? ""), Buffer.from(b.symbol ?? "")));
17339
+ const truncated = unique.length > cap || extra.truncated === true;
17340
+ return finish(unique, {
17341
+ ...extra,
17342
+ inheritanceQuery: {
17343
+ version: 1,
17344
+ mode: aggregate || apex === "not-selected" ? "name-aggregate" : "selected-target",
17345
+ partial: partial || truncated,
17346
+ truncated,
17347
+ apex,
17348
+ },
17349
+ ...(truncated ? { truncated: true, hasMore: true } : {}),
17350
+ });
17351
+ };
17352
+ const apexCandidates = db
17353
+ .query("SELECT * FROM symbols WHERE kind IN ('class','interface')")
17354
+ .all()
17355
+ .filter((row) => /\.(?:cls|trigger)$/i.test(row.filePath) &&
17356
+ (!selector.file || selector.file === row.filePath) &&
17357
+ (!selector.identity ||
17358
+ symbolIdentityMatches(row.identity ?? "", selector.identity)) &&
17359
+ (!selector.kind || selector.kind === row.kind) &&
17360
+ row.name.toLowerCase() === target.split(".").at(-1)?.toLowerCase());
17361
+ const hasApexInventory = aggregate &&
17362
+ db
17363
+ .query("SELECT 1 AS present FROM index_state WHERE lower(filePath) LIKE '%.cls' OR lower(filePath) LIKE '%.trigger' LIMIT 1")
17364
+ .get() != null;
17365
+ if (apexCandidates.length ||
17366
+ selector.file?.match(/\.(?:cls|trigger)$/i) ||
17367
+ hasApexInventory) {
17368
+ try {
17369
+ const targetPage = readClassConsumerPage(db, repoPath, {
17370
+ symbol: target,
17371
+ selector,
17372
+ testScope: "all",
17373
+ limit: 1,
17374
+ });
17375
+ const symbols = db
17376
+ .query("SELECT * FROM symbols WHERE kind IN ('class','interface') AND identity IS NOT NULL")
17377
+ .all();
17378
+ const selected = symbols.find((row) => row.identity === targetPage.targetIdentity);
17379
+ if (!selected)
17380
+ throw new Error("Apex heritage target changed within the read lease");
17381
+ const stored = db
17382
+ .query("SELECT f.* FROM apex_consumer_files f JOIN index_state i ON i.filePath=f.filePath ORDER BY f.filePath COLLATE BINARY")
17383
+ .all();
17384
+ const facts = stored
17385
+ .map(readApexConsumerFacts)
17386
+ .filter((file) => file !== null);
17387
+ const catalog = createApexConsumerCatalog(facts);
17388
+ const cache = new Map(facts.map((file) => [file.file, file]));
17389
+ const targetOwner = apexConsumerSymbolOwner(db, selected, cache);
17390
+ const rows = [];
17391
+ let work = 0;
17392
+ let partial = facts.length !== stored.length || targetPage.analysis.missingDescriptorFiles > 0;
17393
+ for (const file of facts) {
17394
+ if (file.heritageComplete !== true)
17395
+ partial = true;
17396
+ if (file.parseState !== "complete" || file.heritage === undefined) {
17397
+ partial = true;
17398
+ continue;
17399
+ }
17400
+ for (const edge of file.heritage) {
17401
+ if (++work > 50000 || rows.length >= cap + 1) {
17402
+ partial = true;
17403
+ break;
17404
+ }
17405
+ const site = {
17406
+ version: 1,
17407
+ kind: "variable_type",
17408
+ qualifiedSpelling: edge.typePath,
17409
+ typePath: edge.typePath,
17410
+ memberName: null,
17411
+ lexicalOwner: edge.ownerPath,
17412
+ start: edge.start,
17413
+ end: edge.end,
17414
+ blockedReason: null,
17415
+ };
17416
+ const proof = resolveApexClassUses(catalog, { ...file, sites: [site] })[0];
17417
+ if (proof.status !== "resolved") {
17418
+ partial = true;
17419
+ continue;
17420
+ }
17421
+ if (proof.target?.file !== selected.filePath ||
17422
+ proof.target.declaration.ownerPath !== targetOwner)
17423
+ continue;
17424
+ const owners = symbols.filter((row) => row.filePath === file.file &&
17425
+ apexConsumerSymbolOwner(db, row, cache) === edge.ownerPath);
17426
+ if (owners.length !== 1) {
17427
+ partial = true;
17428
+ continue;
17429
+ }
17430
+ const sourceKind = owners[0].kind;
17431
+ const targetKind = proof.target.declaration.kind;
17432
+ if (!(edge.kind === "implements"
17433
+ ? sourceKind === "class" && targetKind === "interface"
17434
+ : (sourceKind === "class" && targetKind === "class") ||
17435
+ (sourceKind === "interface" && targetKind === "interface"))) {
17436
+ partial = true;
17437
+ continue;
17438
+ }
17439
+ rows.push({
17440
+ symbol: owners[0].name,
17441
+ identity: owners[0].identity ?? undefined,
17442
+ file: file.file,
17443
+ kind: edge.kind,
17444
+ line: edge.start.line,
17445
+ column: edge.start.column,
17446
+ heritageEvidence: {
17447
+ spelling: edge.typePath,
17448
+ targetIdentity: targetPage.targetIdentity,
17449
+ grounding: "syntax-resolved",
17450
+ },
17451
+ });
17452
+ }
17453
+ if (work > 50000 || rows.length >= cap + 1)
17454
+ break;
17455
+ }
17456
+ return finishHeritage(rows, "resolved", partial, {
17457
+ ...(work > 50000 ? { truncated: true } : {}),
17458
+ ...(!aggregate ? { targetResolution: "resolved" } : {}),
17459
+ apexHeritageCapability: {
17460
+ version: 1,
17461
+ partial,
17462
+ limitations: [
17463
+ "Explicit source heritage resolved to unique indexed Apex type identities; not calls or runtime dispatch.",
17464
+ "Malformed, legacy, ambiguous or unsupported heritage is withheld; scope is the local indexed inventory.",
17465
+ ],
17466
+ },
17467
+ });
17468
+ }
17469
+ catch (error) {
17470
+ if (!aggregate || !(error instanceof ClassConsumerQueryError))
17471
+ throw error;
17472
+ const state = error.code === "CLASS_CONSUMER_TARGET_AMBIGUOUS"
17473
+ ? "ambiguous"
17474
+ : error.code === "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING"
17475
+ ? "capability-missing"
17476
+ : error.code === "CLASS_CONSUMER_TARGET_NOT_FOUND"
17477
+ ? "not-found"
17478
+ : null;
17479
+ if (!state)
17480
+ throw error;
17481
+ return finishHeritage([], state, true);
17482
+ }
17483
+ }
17484
+ return finishHeritage([], "not-selected", false);
17485
+ }
17486
+ finally {
17487
+ db.run("RELEASE apex_heritage_read");
17488
+ }
15469
17489
  }
15470
17490
  case "structural_implementations_of": {
15471
17491
  const stmt = db.query("SELECT DISTINCT callerSymbol, callerFile, kind, line FROM \"references\" WHERE calleeSymbol = ? AND calleeFile = ? AND kind = 'structural_implements' AND callerSymbol IS NOT NULL");
@@ -15710,49 +17730,229 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15710
17730
  // Explicit compatibility mode for PR/change-set impact. It is intentionally
15711
17731
  // labeled file-level and remains an upstream blast-radius query.
15712
17732
  if (mode === "file") {
15713
- const files = target
15714
- .split(",")
15715
- .map((f) => f.trim())
15716
- .filter(Boolean);
15717
- const defined = [];
15718
- for (const f of files) {
15719
- const stmt = db.query("SELECT name FROM symbols WHERE filePath = ?");
15720
- for (const r of stmt.all(f))
15721
- defined.push({ name: r.name, file: f });
15722
- stmt.finalize();
15723
- }
17733
+ const maxFiles = 256;
17734
+ const maxEdges = 4096;
17735
+ const maxSeeds = 32;
17736
+ const reasons = new Set();
17737
+ const omissions = new Set();
17738
+ const requested = [
17739
+ ...new Set(target
17740
+ .split(",")
17741
+ .map((file) => file.trim())
17742
+ .filter(Boolean)),
17743
+ ];
17744
+ if (requested.length > maxSeeds)
17745
+ reasons.add("seed-limit");
17746
+ const validFiles = new Map();
17747
+ const indexedFile = db.query("SELECT 1 AS present FROM index_state WHERE filePath = ?");
17748
+ const sealed = sealedRepositories.get(resolvedRepoPath);
17749
+ const canonicalFile = (file) => {
17750
+ const cached = validFiles.get(file);
17751
+ if (cached !== undefined)
17752
+ return cached;
17753
+ let valid = false;
17754
+ try {
17755
+ if (file &&
17756
+ file.length <= 1024 &&
17757
+ !file.includes("\\") &&
17758
+ !file.includes("\0") &&
17759
+ !path.posix.isAbsolute(file) &&
17760
+ !/^[A-Za-z]:/.test(file) &&
17761
+ path.posix.normalize(file) === file &&
17762
+ !file.split("/").includes("..") &&
17763
+ !file.startsWith(".knodin/") &&
17764
+ isIndexableSourcePath(file) &&
17765
+ indexedFile.get(file)) {
17766
+ if (sealed) {
17767
+ valid = !!sealed
17768
+ .query("SELECT 1 AS present FROM sealed_file WHERE filePath = ?")
17769
+ .get(file);
17770
+ }
17771
+ else {
17772
+ let current = resolvedRepoPath;
17773
+ valid = true;
17774
+ for (const part of file.split("/")) {
17775
+ current = path.join(current, part);
17776
+ if (fs.lstatSync(current).isSymbolicLink()) {
17777
+ valid = false;
17778
+ break;
17779
+ }
17780
+ }
17781
+ if (valid)
17782
+ valid = fs.lstatSync(current).isFile();
17783
+ }
17784
+ }
17785
+ }
17786
+ catch {
17787
+ valid = false;
17788
+ }
17789
+ validFiles.set(file, valid);
17790
+ return valid;
17791
+ };
17792
+ const files = requested.slice(0, maxSeeds).filter((file) => {
17793
+ if (canonicalFile(file))
17794
+ return true;
17795
+ omissions.add("A requested path is not an exact indexed regular source file; no basename or symbol-name fallback was used.");
17796
+ return false;
17797
+ });
17798
+ const seeds = new Set(files);
15724
17799
  const seen = new Set();
15725
17800
  const rows = [];
15726
- const stmt = db.query('SELECT DISTINCT callerSymbol, callerFile, line, kind, confidence FROM "references" WHERE calleeSymbol = ? AND calleeFile = ? AND callerSymbol IS NOT NULL');
15727
- for (const definition of defined) {
15728
- for (const r of stmt.all(definition.name, definition.file)) {
15729
- if (files.includes(r.callerFile))
15730
- continue;
15731
- const key = `${r.callerSymbol}|${r.callerFile}`;
15732
- if (seen.has(key))
15733
- continue;
15734
- seen.add(key);
15735
- if (!allowed(r.kind, r.confidence, r.callerFile))
15736
- continue;
15737
- rows.push({
15738
- symbol: r.callerSymbol,
15739
- file: r.callerFile,
15740
- line: r.line,
15741
- depth: 1,
15742
- impactEdge: {
15743
- from: r.callerSymbol,
15744
- fromFile: r.callerFile,
15745
- to: definition.name,
15746
- toFile: definition.file,
15747
- kind: r.kind,
15748
- confidence: r.confidence,
15749
- line: r.line,
15750
- },
15751
- });
17801
+ let examinedEdges = 0;
17802
+ const resultFull = () => rows.length >= cap;
17803
+ const refs = db.query('SELECT r.* FROM "references" r WHERE r.calleeFile = ? AND r.callerSymbol IS NOT NULL AND EXISTS (SELECT 1 FROM symbols s WHERE s.filePath = r.calleeFile AND s.name = r.calleeSymbol) ORDER BY r.id LIMIT ?');
17804
+ const referenceSafe = apexReferenceReadGuard(db);
17805
+ try {
17806
+ // Preserve direct named callers; file dependency rows do not invent symbol identities.
17807
+ for (const file of files) {
17808
+ if (examinedEdges >= maxEdges) {
17809
+ reasons.add("work-limit");
17810
+ break;
17811
+ }
17812
+ const candidates = refs.all(file, maxEdges - examinedEdges + 1);
17813
+ for (const ref of candidates) {
17814
+ if (examinedEdges >= maxEdges) {
17815
+ reasons.add("work-limit");
17816
+ break;
17817
+ }
17818
+ examinedEdges++;
17819
+ if (!ref.callerSymbol ||
17820
+ seeds.has(ref.callerFile) ||
17821
+ !referenceSafe(ref) ||
17822
+ !canonicalFile(ref.callerFile) ||
17823
+ !allowed(ref.kind, ref.confidence ?? 1, ref.callerFile))
17824
+ continue;
17825
+ const key = JSON.stringify([ref.callerSymbol, ref.callerFile]);
17826
+ if (seen.has(key))
17827
+ continue;
17828
+ seen.add(key);
17829
+ if (resultFull()) {
17830
+ reasons.add("result-limit");
17831
+ break;
17832
+ }
17833
+ rows.push({
17834
+ symbol: ref.callerSymbol,
17835
+ file: ref.callerFile,
17836
+ line: ref.line,
17837
+ depth: 1,
17838
+ impactEdge: {
17839
+ from: ref.callerSymbol,
17840
+ fromFile: ref.callerFile,
17841
+ to: ref.calleeSymbol,
17842
+ toFile: file,
17843
+ kind: ref.kind,
17844
+ confidence: ref.confidence ?? 1,
17845
+ line: ref.line,
17846
+ },
17847
+ });
17848
+ }
17849
+ if (reasons.has("result-limit"))
17850
+ break;
15752
17851
  }
15753
17852
  }
15754
- stmt.finalize();
15755
- return decorate(rows, "upstream");
17853
+ finally {
17854
+ refs.finalize();
17855
+ }
17856
+ const incoming = db.query("SELECT id,fromFile,toFile,kind,confidence,sourceEvidence FROM dependencies WHERE toFile = ? ORDER BY id LIMIT ?");
17857
+ const visited = new Set(files);
17858
+ const queue = files.map((file) => ({ file, depth: 0 }));
17859
+ try {
17860
+ for (let index = 0; index < queue.length; index++) {
17861
+ if (reasons.has("result-limit"))
17862
+ break;
17863
+ if (examinedEdges >= maxEdges) {
17864
+ reasons.add("work-limit");
17865
+ break;
17866
+ }
17867
+ const current = queue[index];
17868
+ for (const edge of incoming.all(current.file, maxEdges - examinedEdges + 1)) {
17869
+ if (examinedEdges >= maxEdges) {
17870
+ reasons.add("work-limit");
17871
+ break;
17872
+ }
17873
+ examinedEdges++;
17874
+ if (!isFileRelationship(edge.kind, edge.fromFile, true) ||
17875
+ !isFileRelationship(edge.kind, edge.toFile, false)) {
17876
+ omissions.add("Unsupported or non-file dependency kinds were not traversed.");
17877
+ continue;
17878
+ }
17879
+ if (!canonicalFile(edge.fromFile) || !canonicalFile(edge.toFile)) {
17880
+ omissions.add("Noncanonical, missing, symlinked, or unindexed dependency endpoints were not traversed.");
17881
+ continue;
17882
+ }
17883
+ const confidence = edge.confidence ?? 1;
17884
+ if (!Number.isFinite(confidence) ||
17885
+ confidence < 0 ||
17886
+ confidence > 1 ||
17887
+ !allowed(edge.kind, confidence, edge.fromFile) ||
17888
+ visited.has(edge.fromFile))
17889
+ continue;
17890
+ if (current.depth >= maxDepth) {
17891
+ reasons.add("depth-limit");
17892
+ continue;
17893
+ }
17894
+ if (visited.size >= maxFiles) {
17895
+ reasons.add("file-limit");
17896
+ continue;
17897
+ }
17898
+ if (resultFull()) {
17899
+ reasons.add("result-limit");
17900
+ break;
17901
+ }
17902
+ visited.add(edge.fromFile);
17903
+ queue.push({ file: edge.fromFile, depth: current.depth + 1 });
17904
+ rows.push({
17905
+ file: edge.fromFile,
17906
+ kind: edge.kind,
17907
+ depth: current.depth + 1,
17908
+ ...(edge.sourceEvidence ? { sourceEvidence: edge.sourceEvidence } : {}),
17909
+ impactEdge: {
17910
+ from: edge.fromFile,
17911
+ fromFile: edge.fromFile,
17912
+ to: edge.toFile,
17913
+ toFile: edge.toFile,
17914
+ kind: edge.kind,
17915
+ confidence,
17916
+ ...(edge.sourceEvidence ? { sourceEvidence: edge.sourceEvidence } : {}),
17917
+ },
17918
+ });
17919
+ }
17920
+ }
17921
+ }
17922
+ finally {
17923
+ incoming.finalize();
17924
+ indexedFile.finalize();
17925
+ }
17926
+ omissions.add("File mode reports direct named callers and bounded upstream file dependencies, not runtime reachability; whole-graph flow/community decoration is omitted.");
17927
+ return finish(rows, {
17928
+ ...(reasons.size ? { hasMore: true } : {}),
17929
+ impact: {
17930
+ mode,
17931
+ direction: "upstream",
17932
+ maxDepth,
17933
+ heuristic: true,
17934
+ summary: [
17935
+ rows.length,
17936
+ "heuristic upstream file-level results; bounded dependency traversal",
17937
+ reasons.size ? "(partial)" : "",
17938
+ ]
17939
+ .filter(Boolean)
17940
+ .join(" "),
17941
+ affectedFlows: [],
17942
+ affectedCommunities: [],
17943
+ traversal: {
17944
+ maxFiles,
17945
+ maxEdges,
17946
+ maxSeeds,
17947
+ visitedFiles: visited.size,
17948
+ examinedEdges,
17949
+ truncated: reasons.size > 0,
17950
+ reasons: [...reasons],
17951
+ omissions: [...omissions],
17952
+ countIsLowerBound: reasons.size > 0,
17953
+ },
17954
+ },
17955
+ });
15756
17956
  }
15757
17957
  if (!selectedTarget)
15758
17958
  return decorate([], direction);
@@ -16040,10 +18240,31 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
16040
18240
  // not recognized (for example, a dynamic loader). Keep it live.
16041
18241
  return !matchedTarget;
16042
18242
  };
16043
- const stmt = db.query("SELECT name, kind, filePath, startLine FROM symbols WHERE kind IN ('function', 'class', 'method')");
18243
+ const hasApexDeclarations = db
18244
+ .query("PRAGMA table_info(symbols)")
18245
+ .all()
18246
+ .some((column) => column.name === "apexDeclaration");
18247
+ const stmt = db.query(`SELECT id, identity, name, kind, filePath, startLine, ${hasApexDeclarations ? "apexDeclaration" : "NULL AS apexDeclaration"} FROM symbols WHERE kind IN ('function', 'class', 'method')`);
16044
18248
  let rows = stmt.all();
16045
18249
  stmt.finalize();
18250
+ if (target)
18251
+ rows = rows.filter((row) => row.filePath.includes(target));
18252
+ const declarations = [];
18253
+ const unsupportedApexFiles = new Set();
18254
+ for (const row of rows) {
18255
+ if (!/\.cls$/i.test(row.filePath))
18256
+ continue;
18257
+ const declaration = parseApexDeclaration(row.apexDeclaration);
18258
+ if (declaration)
18259
+ declarations.push({ key: String(row.id), file: row.filePath, declaration });
18260
+ else
18261
+ unsupportedApexFiles.add(row.filePath);
18262
+ }
18263
+ const protectedApexEntries = apexOrgEntryPoints(declarations);
18264
+ const capability = apexReceiverCapability(db);
16046
18265
  rows = rows.filter((r) => {
18266
+ if (protectedApexEntries.has(String(r.id)) || unsupportedApexFiles.has(r.filePath))
18267
+ return false;
16047
18268
  if (r.kind === "variable")
16048
18269
  return false;
16049
18270
  if (r.name.length <= 3)
@@ -16082,6 +18303,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
16082
18303
  file: r.filePath,
16083
18304
  line: r.startLine,
16084
18305
  heuristic: true,
18306
+ identity: r.identity ?? undefined,
16085
18307
  sourceEvidence: "No statically resolved references; runtime dispatch, reflection, aliases, and indirect exports remain unresolved.",
16086
18308
  drillDown: {
16087
18309
  operation: "query",
@@ -16090,7 +18312,21 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
16090
18312
  file: r.filePath,
16091
18313
  limit: 25,
16092
18314
  },
16093
- })));
18315
+ })), {
18316
+ deadCodeAnalysis: {
18317
+ protectedApexEntries: protectedApexEntries.size,
18318
+ unsupportedApexFiles: unsupportedApexFiles.size,
18319
+ repositoryMissingApexCapabilityFiles: capability.missingFiles,
18320
+ partial: unsupportedApexFiles.size > 0 || capability.partial,
18321
+ limitations: [
18322
+ "Org-facing Apex declarations are excluded; this does not prove runtime use or root reachability. Global visibility alone is not an exemption.",
18323
+ "Remaining results are static candidates, not safe-to-delete findings; dead chains and dynamic dispatch are not established.",
18324
+ ...(unsupportedApexFiles.size > 0 || capability.partial
18325
+ ? [capability.repair]
18326
+ : []),
18327
+ ],
18328
+ },
18329
+ });
16094
18330
  }
16095
18331
  case "cross_substrate_path": {
16096
18332
  const unsupportedCrossings = [
@@ -16147,7 +18383,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
16147
18383
  ...flowSource.matchAll(/<actionCalls>([\s\S]*?)<\/actionCalls>/g),
16148
18384
  ]
16149
18385
  .filter((match) => /<actionType>apex<\/actionType>/.test(match[1]))
16150
- .filter((match) => staticXmlTagValues(match[1], "actionName").some((entry) => entry.value === actionName));
18386
+ .filter((match) => staticXmlTagValues(match[1], "actionName").some((entry) => entry.value === actionName ||
18387
+ (entry.value === className &&
18388
+ findUniqueInvocableApexMethod(className, undefined, primary.path)?.method ===
18389
+ destination.selected?.name)));
16151
18390
  if (matchingActions.length === 1)
16152
18391
  flowEvidence = matchingActions[0][0].trim();
16153
18392
  }
@@ -17642,6 +19881,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
17642
19881
  }
17643
19882
  for (const repo of releasable) {
17644
19883
  const db = dbInstances.get(repo);
19884
+ if (db)
19885
+ apexConsumerValidationMemo.delete(db);
17645
19886
  if (db)
17646
19887
  db.close();
17647
19888
  dbInstances.delete(repo);
@@ -17650,6 +19891,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
17650
19891
  }
17651
19892
  for (const key of explicitDatabaseKeys) {
17652
19893
  const db = dbInstances.get(key);
19894
+ if (db)
19895
+ apexConsumerValidationMemo.delete(db);
17653
19896
  if (db)
17654
19897
  db.close();
17655
19898
  dbInstances.delete(key);