knodin 0.8.4 → 0.8.6

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 (34) hide show
  1. package/dist/bin/cli.js +272 -24
  2. package/dist/src/cli-model.js +26 -0
  3. package/dist/src/engine/candidate-database.js +167 -0
  4. package/dist/src/engine/embeddings.js +8 -5
  5. package/dist/src/engine/index.js +1244 -305
  6. package/dist/src/engine/prune.js +8 -0
  7. package/dist/src/file-metrics.js +153 -0
  8. package/dist/src/repair-lease.js +8 -5
  9. package/dist/src/response-budget.js +2 -1
  10. package/dist/src/shared-index/artifact.js +65 -0
  11. package/dist/src/shared-index/cache.js +267 -0
  12. package/dist/src/shared-index/compatibility.js +70 -0
  13. package/dist/src/shared-index/config.js +224 -0
  14. package/dist/src/shared-index/contract.js +117 -0
  15. package/dist/src/shared-index/manifest.js +71 -0
  16. package/dist/src/shared-index/opportunistic.js +37 -0
  17. package/dist/src/shared-index/overlay.js +98 -0
  18. package/dist/src/shared-index/provenance.js +149 -0
  19. package/dist/src/shared-index/publisher.js +198 -0
  20. package/dist/src/shared-index/restore.js +209 -0
  21. package/dist/src/shared-index/s3-client.js +124 -0
  22. package/dist/src/shared-index/schemas.js +86 -0
  23. package/dist/src/shared-index/selection.js +111 -0
  24. package/dist/src/tools/knodin-tools.js +70 -3
  25. package/docs/CLI.md +39 -0
  26. package/docs/MCP.md +12 -0
  27. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  28. package/docs/releases/0.8.5.md +25 -0
  29. package/docs/releases/0.8.6.md +10 -0
  30. package/package.json +12 -3
  31. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  32. package/schemas/shared-index-config-v1.schema.json +97 -0
  33. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  34. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import chokidar from "chokidar";
20
20
  import Parser from "web-tree-sitter";
21
21
  import { compareBytes } from "../compare.js";
22
+ import { collectFileMetrics } from "../file-metrics.js";
22
23
  import { readIndexActivity } from "../index-activity.js";
23
24
  import { runReadOnlyLspQuery } from "../lsp-readonly.js";
24
25
  import { acquireRepairLease } from "../repair-lease.js";
@@ -26,6 +27,7 @@ import { analyzeResourceReachability, resourceFingerprint, } from "../resource-r
26
27
  import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snapshot.js";
27
28
  import { KNODIN_VERSION } from "../version.js";
28
29
  import * as ann from "./ann-hnsw.js";
30
+ import { allocateCandidate, assertCandidate, discardCandidateFiles, promoteCandidateFile, recoverInterruptedPromotion, } from "./candidate-database.js";
29
31
  import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
30
32
  import { walkRepoFiles } from "./file-walker.js";
31
33
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
@@ -59,6 +61,7 @@ export const REPO_WIDE_QUERY_PATTERNS = [
59
61
  "dead_code",
60
62
  "large_functions",
61
63
  "large_files",
64
+ "file_metrics",
62
65
  "import_cycles",
63
66
  "flows",
64
67
  "stats",
@@ -544,6 +547,20 @@ function persistSymbolIdentities(db, repoPath, filePaths) {
544
547
  throw error;
545
548
  }
546
549
  }
550
+ /** Repair-only cancellable identity pass, bounded to one source file per transaction. */
551
+ async function persistSymbolIdentitiesCancellable(db, repoPath, filePaths, signal) {
552
+ const scope = filePaths === undefined
553
+ ? db
554
+ .query("SELECT DISTINCT filePath FROM symbols ORDER BY filePath")
555
+ .all()
556
+ .map((row) => row.filePath)
557
+ : (scopedFilePaths(filePaths) ?? []);
558
+ for (const file of scope) {
559
+ throwIfAborted(signal);
560
+ persistSymbolIdentities(db, repoPath, [file]);
561
+ await yieldToIndexEventLoop();
562
+ }
563
+ }
547
564
  /** Bind name/file reference rows to exact definitions after identities exist. */
548
565
  function persistReferenceEndpoints(db, filePaths) {
549
566
  const refs = referencesForFiles(db, filePaths);
@@ -661,7 +678,31 @@ function symbolIdentityMatches(candidate, selector) {
661
678
  const dbInstances = new Map();
662
679
  const initPromises = new Map();
663
680
  const watchers = new Map();
681
+ const watcherStates = new Map();
664
682
  const WATCH_DEBOUNCE_MS = 50;
683
+ const MAX_RECURSIVE_WATCH_FILES = 20_000;
684
+ function explicitDatabasePath(repoPath, databasePath) {
685
+ if (!databasePath)
686
+ return undefined;
687
+ const stateDir = path.resolve(resolveStateDir(repoPath));
688
+ const resolved = path.resolve(databasePath);
689
+ if (resolved === path.resolve(resolveDbPath(repoPath)))
690
+ throw new Error("knodin candidate database must be distinct from the active database");
691
+ const relative = path.relative(stateDir, resolved);
692
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
693
+ throw new Error("knodin candidate database must be a distinct path inside repository state");
694
+ }
695
+ let cursor = stateDir;
696
+ for (const segment of relative.split(path.sep)) {
697
+ cursor = path.join(cursor, segment);
698
+ if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink())
699
+ throw new Error("knodin candidate database path must not contain symbolic links");
700
+ }
701
+ return resolved;
702
+ }
703
+ function databaseCacheKey(repoPath, databasePath) {
704
+ return explicitDatabasePath(repoPath, databasePath) ?? path.resolve(repoPath);
705
+ }
665
706
  const watchQueues = new Map();
666
707
  const repositoryEngineOwners = new Map();
667
708
  /**
@@ -717,11 +758,32 @@ export async function __evictRemovedRepoState() {
717
758
  }
718
759
  dbInstances.delete(key);
719
760
  initPromises.delete(key);
761
+ watcherStates.delete(key);
720
762
  }
721
763
  }
764
+ function freshnessMechanismFor(repoPath, policy) {
765
+ if (policy.watcher === "disabled") {
766
+ return {
767
+ strategy: "git-probes-and-lifecycle-hooks",
768
+ watcher: "disabled",
769
+ };
770
+ }
771
+ const state = watcherStates.get(path.resolve(repoPath));
772
+ if (state?.watcher === "active") {
773
+ return {
774
+ strategy: "watcher-git-probes-and-lifecycle-hooks",
775
+ watcher: "active",
776
+ };
777
+ }
778
+ return {
779
+ strategy: "git-probes-and-lifecycle-hooks",
780
+ watcher: state?.watcher ?? "disabled",
781
+ ...(state?.diagnostic ? { diagnostic: state.diagnostic } : {}),
782
+ };
783
+ }
722
784
  // Bumped whenever any repo is (re)indexed, so cached community-detection results
723
785
  // below can be invalidated cheaply instead of recomputed on every call.
724
- const KNODIN_SCHEMA_VERSION = 22;
786
+ export const KNODIN_SCHEMA_VERSION = 22;
725
787
  const LAST_REBUILD_SCHEMA_VERSION = 22;
726
788
  let indexGeneration = 0;
727
789
  const resourceReachabilityCache = new Map();
@@ -843,18 +905,127 @@ function getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath, topN = 15, relati
843
905
  const statusCache = new Map();
844
906
  const repairOperations = new Map();
845
907
  const STATUS_CACHE_LIMIT = 16;
846
- function databaseFileFingerprint(databasePath) {
847
- return [databasePath, `${databasePath}-wal`]
908
+ export const ADAPTIVE_STATUS_FILE_THRESHOLD = 50_000;
909
+ const PERSISTED_AUDIT_VERSION = 1;
910
+ export function adaptiveStatusStrategy(indexedFiles) {
911
+ const testThreshold = process.env.KNODIN_TEST_ADAPTIVE_STATUS_THRESHOLD !== undefined
912
+ ? Number(process.env.KNODIN_TEST_ADAPTIVE_STATUS_THRESHOLD)
913
+ : ADAPTIVE_STATUS_FILE_THRESHOLD;
914
+ return indexedFiles <= testThreshold ? "deep" : "persisted";
915
+ }
916
+ function throwIfAborted(signal) {
917
+ if (!signal?.aborted)
918
+ return;
919
+ throw signal.reason instanceof Error
920
+ ? signal.reason
921
+ : new DOMException("The operation was aborted", "AbortError");
922
+ }
923
+ function wasAborted(error, signal) {
924
+ return (signal.aborted &&
925
+ (error === signal.reason || (error instanceof Error && error.name === "AbortError")));
926
+ }
927
+ function canonicalRepository(repoPath) {
928
+ try {
929
+ return fs.realpathSync.native(repoPath);
930
+ }
931
+ catch {
932
+ return path.resolve(repoPath);
933
+ }
934
+ }
935
+ function persistedStatusAuditPath(databasePath) {
936
+ return path.join(path.dirname(databasePath), "status-audit-v1.json");
937
+ }
938
+ function readPersistedStatusAudit(repoPath, databasePath, databaseFingerprint, schemaVersion) {
939
+ try {
940
+ const parsed = JSON.parse(fs.readFileSync(persistedStatusAuditPath(databasePath), "utf8"));
941
+ if (parsed.format !== PERSISTED_AUDIT_VERSION ||
942
+ parsed.repository !== canonicalRepository(repoPath) ||
943
+ parsed.schemaVersion !== schemaVersion ||
944
+ parsed.knodinVersion !== KNODIN_VERSION ||
945
+ parsed.databaseFingerprint !== databaseFingerprint ||
946
+ parsed.result?.status !== "healthy" ||
947
+ parsed.result?.verification?.mode !== "deep-audit" ||
948
+ parsed.auditVerifiedAt !== parsed.result.verification.verifiedAt)
949
+ return null;
950
+ return parsed;
951
+ }
952
+ catch {
953
+ return null;
954
+ }
955
+ }
956
+ function writePersistedStatusAudit(repoPath, databasePath, databaseFingerprint, result) {
957
+ if (result.status !== "healthy" || result.verification.mode !== "deep-audit")
958
+ return;
959
+ const target = persistedStatusAuditPath(databasePath);
960
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
961
+ const snapshot = {
962
+ format: PERSISTED_AUDIT_VERSION,
963
+ repository: canonicalRepository(repoPath),
964
+ schemaVersion: result.schemaVersion,
965
+ knodinVersion: KNODIN_VERSION,
966
+ databaseFingerprint,
967
+ auditVerifiedAt: result.verification.verifiedAt,
968
+ result: structuredClone(result),
969
+ };
970
+ try {
971
+ fs.mkdirSync(path.dirname(target), { recursive: true });
972
+ fs.writeFileSync(temporary, `${JSON.stringify(snapshot)}\n`, { mode: 0o600, flag: "wx" });
973
+ fs.renameSync(temporary, target);
974
+ }
975
+ finally {
976
+ fs.rmSync(temporary, { force: true });
977
+ }
978
+ }
979
+ function databaseFileFingerprint(databasePath, db) {
980
+ const files = [databasePath, `${databasePath}-wal`]
848
981
  .map((file) => {
849
982
  try {
850
983
  const stat = fs.statSync(file);
851
- return `${stat.size}:${stat.mtimeMs}`;
984
+ if (file.endsWith("-wal") && stat.size === 0)
985
+ return "missing";
986
+ const descriptor = fs.openSync(file, "r");
987
+ try {
988
+ const header = Buffer.alloc(Math.min(100, stat.size));
989
+ fs.readSync(descriptor, header, 0, header.length, 0);
990
+ // SQLite rewrites volatile header counters during a clean close even
991
+ // when graph contents are unchanged. Logical DB evidence below binds
992
+ // mutations without making process shutdown invalidate the snapshot.
993
+ if (!file.endsWith("-wal") && header.length >= 40)
994
+ header.fill(0, 24, 40);
995
+ return `${stat.size}:${crypto.createHash("sha256").update(header).digest("hex")}`;
996
+ }
997
+ finally {
998
+ fs.closeSync(descriptor);
999
+ }
852
1000
  }
853
1001
  catch {
854
1002
  return "missing";
855
1003
  }
856
1004
  })
857
1005
  .join("|");
1006
+ if (!db)
1007
+ return files;
1008
+ try {
1009
+ const counts = ["symbols", "references", "dependencies", "symbol_embeddings", "index_state"]
1010
+ .map((table) => db.query(`SELECT COUNT(*) count FROM "${table}"`).get()?.count ??
1011
+ 0)
1012
+ .join(":");
1013
+ const state = db
1014
+ .query("SELECT COUNT(*) count, COALESCE(SUM(mtimeMs),0) mtimes, COALESCE(SUM(size),0) sizes FROM index_state")
1015
+ .get();
1016
+ const meta = db
1017
+ .query("SELECT key, value FROM meta ORDER BY key")
1018
+ .all()
1019
+ .map((row) => `${row.key}=${row.value}`)
1020
+ .join("\0");
1021
+ return `${files}|${crypto
1022
+ .createHash("sha256")
1023
+ .update(`${counts}|${state?.count}:${state?.mtimes}:${state?.sizes}|${meta}`)
1024
+ .digest("hex")}`;
1025
+ }
1026
+ catch {
1027
+ return files;
1028
+ }
858
1029
  }
859
1030
  const traversalSnapshotCache = new Map();
860
1031
  const TRAVERSAL_SNAPSHOT_CACHE_LIMIT = 8;
@@ -6516,9 +6687,10 @@ async function embedPreparedBatch(items, onModelProgress) {
6516
6687
  }
6517
6688
  }
6518
6689
  /** Incrementally generates and saves embeddings for symbols that lack them. */
6519
- async function indexEmbeddings(db, repoPath, progress) {
6690
+ async function indexEmbeddings(db, repoPath, progress, signal) {
6520
6691
  return measurePerfPhase("embedding_inference_write", async () => {
6521
6692
  try {
6693
+ throwIfAborted(signal);
6522
6694
  const missingStmt = db.query(`
6523
6695
  SELECT id, name, kind, filePath, startLine, endLine, summary
6524
6696
  FROM symbols
@@ -6560,6 +6732,7 @@ async function indexEmbeddings(db, repoPath, progress) {
6560
6732
  const sourceForEmbedding = createEmbeddingSourceReader(repoPath);
6561
6733
  let lastYieldAt = Date.now();
6562
6734
  for (let offset = 0; offset < missing.length; offset += batchSize) {
6735
+ throwIfAborted(signal);
6563
6736
  const prepared = missing.slice(offset, offset + batchSize).map((sym) => {
6564
6737
  const source = sourceForEmbedding(sym.filePath, sym.startLine, sym.endLine);
6565
6738
  return {
@@ -6568,6 +6741,7 @@ async function indexEmbeddings(db, repoPath, progress) {
6568
6741
  };
6569
6742
  });
6570
6743
  const generated = await embedPreparedBatch(prepared, onModelProgress);
6744
+ throwIfAborted(signal);
6571
6745
  db.run("BEGIN TRANSACTION;");
6572
6746
  try {
6573
6747
  for (const { symbol, embedding: embeddingVec } of generated) {
@@ -6590,6 +6764,7 @@ async function indexEmbeddings(db, repoPath, progress) {
6590
6764
  insertEmbedding.finalize();
6591
6765
  }
6592
6766
  catch (error) {
6767
+ throwIfAborted(signal);
6593
6768
  console.error("Error during indexEmbeddings:", error);
6594
6769
  }
6595
6770
  });
@@ -6766,6 +6941,63 @@ function gitTrackedCandidates(repoPath) {
6766
6941
  return null;
6767
6942
  }
6768
6943
  }
6944
+ async function execGitAbortable(repoPath, args, signal) {
6945
+ throwIfAborted(signal);
6946
+ return await new Promise((resolve, reject) => {
6947
+ child_process.execFile("git", args, {
6948
+ cwd: repoPath,
6949
+ encoding: "utf8",
6950
+ maxBuffer: 64 * 1024 * 1024,
6951
+ signal,
6952
+ }, (error, stdout) => error
6953
+ ? reject(error instanceof Error
6954
+ ? error
6955
+ : new Error(typeof error === "string" ? error : "Git subprocess failed"))
6956
+ : resolve(stdout));
6957
+ });
6958
+ }
6959
+ async function collectRepoFilesWithCoverageAbortable(repoPath, signal) {
6960
+ let candidates = null;
6961
+ try {
6962
+ const gitRoot = (await execGitAbortable(repoPath, ["rev-parse", "--show-toplevel"], signal)).trim();
6963
+ if (gitRoot && isSameDir(gitRoot, repoPath)) {
6964
+ candidates = splitNulPaths(await execGitAbortable(repoPath, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], signal));
6965
+ }
6966
+ }
6967
+ catch {
6968
+ throwIfAborted(signal);
6969
+ }
6970
+ const files = [];
6971
+ const skippedByExtension = new Map();
6972
+ const source = candidates ?? walkRepoFiles(repoPath);
6973
+ for (let index = 0; index < source.length; index++) {
6974
+ if (index % 256 === 0) {
6975
+ throwIfAborted(signal);
6976
+ await yieldToIndexEventLoop();
6977
+ }
6978
+ const file = source[index];
6979
+ if (candidates) {
6980
+ try {
6981
+ if (!fs.statSync(path.join(repoPath, file)).isFile())
6982
+ continue;
6983
+ }
6984
+ catch {
6985
+ continue;
6986
+ }
6987
+ }
6988
+ if (!isIndexablePath(file))
6989
+ continue;
6990
+ if (!isIndexableSourcePath(file)) {
6991
+ tallyOne(skippedByExtension, extensionBucket(file));
6992
+ continue;
6993
+ }
6994
+ if (!file.endsWith(".json") || isIndexableJsonCandidate(file, repoPath))
6995
+ files.push(file);
6996
+ else
6997
+ tallyOne(skippedByExtension, ".json");
6998
+ }
6999
+ return { files: files.sort(compareBytes), skippedByExtension };
7000
+ }
6769
7001
  /**
6770
7002
  * Enumerate the repo-relative files knodin indexes: source files plus dbt
6771
7003
  * `manifest.json` files and Dockerfiles. Shared by the full index and the
@@ -6871,8 +7103,8 @@ function freshnessFingerprint(value) {
6871
7103
  return null;
6872
7104
  return crypto.createHash("sha256").update(value).digest("hex");
6873
7105
  }
6874
- function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride) {
6875
- const git = gitWorkTreeProbe(repoPath);
7106
+ function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProbe) {
7107
+ const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath) : gitProbe;
6876
7108
  const currentHead = git && isSameDir(git.root, repoPath) ? git.head || null : gitHead(repoPath);
6877
7109
  const indexedHead = db ? getMeta(db, "lastIndexedHead") || null : null;
6878
7110
  const relation = commitRelation(repoPath, indexedHead, currentHead);
@@ -7288,7 +7520,7 @@ function gitWorkTreeProbe(repoPath) {
7288
7520
  // Porcelain: `XY <path>` (or `XY <orig> -> <new>` for renames).
7289
7521
  for (const part of line.slice(3).split(" -> ")) {
7290
7522
  const p = part.trim().replace(/^"|"$/g, "");
7291
- if (p)
7523
+ if (p && isIndexablePath(p))
7292
7524
  dirty.push(p);
7293
7525
  }
7294
7526
  }
@@ -7322,7 +7554,7 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
7322
7554
  return st !== null;
7323
7555
  if (!st)
7324
7556
  return true;
7325
- return Math.abs(row.mtimeMs - st.mtimeMs) >= 1.0 || row.size !== st.size;
7557
+ return row.mtimeMs !== st.mtimeMs || row.size !== st.size;
7326
7558
  }
7327
7559
  /**
7328
7560
  * Record the working-tree state the index now reflects. Called at the end of a
@@ -7355,8 +7587,8 @@ function recordFreshnessBaseline(repoPath, db) {
7355
7587
  * pretending. A brand-new file in a non-git repo is consequently picked up by
7356
7588
  * the watcher or a cold start, not by this probe.
7357
7589
  */
7358
- function probeDrift(repoPath, db) {
7359
- const git = gitWorkTreeProbe(repoPath);
7590
+ function probeDrift(repoPath, db, gitProbe) {
7591
+ const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath) : gitProbe;
7360
7592
  if (git && isSameDir(git.root, repoPath)) {
7361
7593
  if ((getMeta(db, "lastIndexedHead") ?? "") !== git.head) {
7362
7594
  return { drifted: true, verifiable: true };
@@ -7462,7 +7694,7 @@ function stalenessFor(repoPath) {
7462
7694
  return freshnessProbes.get(path.resolve(repoPath))?.staleness ?? "unknown";
7463
7695
  }
7464
7696
  /** Recursively indexes all matching files within the repository. */
7465
- async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
7697
+ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publishProcessState = true) {
7466
7698
  progress?.("collecting-files", 0, "Discovering indexable files");
7467
7699
  const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath));
7468
7700
  const files = collected.files;
@@ -7508,17 +7740,26 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
7508
7740
  setMeta(db, "mcpBackfillVersion", "17");
7509
7741
  setMeta(db, COVERAGE_UNPARSED_META_KEY, JSON.stringify(Object.fromEntries(unparsedByExtension)));
7510
7742
  recordFreshnessBaseline(repoPath, db);
7511
- indexGeneration++;
7743
+ if (publishProcessState)
7744
+ indexGeneration++;
7512
7745
  }
7513
7746
  // makeWatchIgnorePredicate: single source of truth is src/engine/prune.ts
7514
7747
  // (R14) — imported above, no local copy here. chokidar v4/v5 accept only a
7515
7748
  // function/regex/literal path (no globs), which is what that shared
7516
7749
  // predicate provides.
7517
7750
  /** Starts a chokidar file watcher to incrementally update SQLite indexes in real-time. */
7518
- function startFileWatcher(repoPath, db) {
7751
+ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_FILES) {
7519
7752
  const resolvedRepoPath = path.resolve(repoPath);
7520
7753
  if (watchers.has(resolvedRepoPath))
7521
7754
  return;
7755
+ const indexedFileCount = db.query("SELECT COUNT(*) count FROM index_state").get()?.count ?? 0;
7756
+ if (indexedFileCount > watcherFileLimit) {
7757
+ watcherStates.set(resolvedRepoPath, {
7758
+ watcher: "scale-limited",
7759
+ diagnostic: "repository-too-large",
7760
+ });
7761
+ return;
7762
+ }
7522
7763
  const queue = {
7523
7764
  pending: new Set(),
7524
7765
  timer: null,
@@ -7633,21 +7874,85 @@ function startFileWatcher(repoPath, db) {
7633
7874
  watcher.on("unlink", enqueue);
7634
7875
  watcher.on("ready", () => {
7635
7876
  queue.ready = true;
7877
+ watcherStates.set(resolvedRepoPath, { watcher: "active" });
7878
+ });
7879
+ watcher.on("error", (error) => {
7880
+ const code = typeof error === "object" && error !== null && "code" in error
7881
+ ? String(error.code)
7882
+ : "";
7883
+ if (degradeWatcherForResourceLimit(resolvedRepoPath, code))
7884
+ return;
7885
+ console.error(`File watcher error for ${resolvedRepoPath}:`, error);
7636
7886
  });
7637
- watcher.on("error", (error) => console.error(`File watcher error for ${resolvedRepoPath}:`, error));
7638
7887
  watchers.set(resolvedRepoPath, watcher);
7888
+ watcherStates.set(resolvedRepoPath, { watcher: "active" });
7889
+ }
7890
+ /** Convert native watch exhaustion into a bounded, query-safe freshness fallback. */
7891
+ export function degradeWatcherForResourceLimit(repoPath, code) {
7892
+ if (code !== "EMFILE" && code !== "ENOSPC")
7893
+ return false;
7894
+ const resolved = path.resolve(repoPath);
7895
+ const queue = watchQueues.get(resolved);
7896
+ if (queue) {
7897
+ queue.closed = true;
7898
+ if (queue.timer)
7899
+ clearTimeout(queue.timer);
7900
+ queue.timer = null;
7901
+ }
7902
+ watcherStates.set(resolved, {
7903
+ watcher: "resource-limited",
7904
+ diagnostic: "file-descriptor-limit",
7905
+ });
7906
+ const watcher = watchers.get(resolved);
7907
+ if (watcher) {
7908
+ void watcher.close().finally(() => {
7909
+ watchers.delete(resolved);
7910
+ watchQueues.delete(resolved);
7911
+ });
7912
+ }
7913
+ return true;
7914
+ }
7915
+ async function suspendFileWatcher(repoPath) {
7916
+ const resolved = path.resolve(repoPath);
7917
+ const watcher = watchers.get(resolved);
7918
+ if (!watcher)
7919
+ return false;
7920
+ await watcher.close();
7921
+ watchers.delete(resolved);
7922
+ const queue = watchQueues.get(resolved);
7923
+ if (queue) {
7924
+ queue.closed = true;
7925
+ if (queue.timer)
7926
+ clearTimeout(queue.timer);
7927
+ queue.timer = null;
7928
+ await queue.drain();
7929
+ watchQueues.delete(resolved);
7930
+ }
7931
+ watcherStates.set(resolved, { watcher: "disabled" });
7932
+ return true;
7639
7933
  }
7640
7934
  /** Open and initialize SQLite connection and full repo index. */
7641
7935
  async function getOrInitDb(repoPath, options = {}) {
7642
7936
  const normalizedPath = path.resolve(repoPath);
7643
- let promise = initPromises.get(normalizedPath);
7937
+ const selectedDatabasePath = explicitDatabasePath(normalizedPath, options.databasePath);
7938
+ if (!selectedDatabasePath)
7939
+ recoverInterruptedPromotion(normalizedPath);
7940
+ const cacheKey = databaseCacheKey(normalizedPath, selectedDatabasePath);
7941
+ let promise = initPromises.get(cacheKey);
7644
7942
  if (!promise) {
7645
7943
  promise = (async () => {
7646
7944
  const isTest = process.env.VITEST || process.env.NODE_ENV === "test";
7647
7945
  let dbPath;
7648
- if (isTest) {
7946
+ if (isTest && !selectedDatabasePath) {
7649
7947
  dbPath = ":memory:";
7650
7948
  }
7949
+ else if (selectedDatabasePath) {
7950
+ await fs.promises.mkdir(path.dirname(selectedDatabasePath), {
7951
+ recursive: true,
7952
+ mode: 0o700,
7953
+ });
7954
+ dbPath = selectedDatabasePath;
7955
+ }
7651
7956
  else {
7652
7957
  const stateDir = resolveStateDir(normalizedPath);
7653
7958
  await fs.promises.mkdir(stateDir, { recursive: true });
@@ -8032,15 +8337,27 @@ async function getOrInitDb(repoPath, options = {}) {
8032
8337
  }
8033
8338
  noteFreshness(normalizedPath, reindexed > 0 ? "reconciled" : "fresh");
8034
8339
  }
8035
- if (!isTest) {
8036
- startFileWatcher(normalizedPath, db);
8340
+ if (!selectedDatabasePath && !isTest && options.watcher !== "disabled") {
8341
+ startFileWatcher(normalizedPath, db, options.watcherFileLimit);
8342
+ }
8343
+ else if (!watchers.has(normalizedPath)) {
8344
+ watcherStates.set(normalizedPath, { watcher: "disabled" });
8037
8345
  }
8038
- dbInstances.set(normalizedPath, db);
8346
+ dbInstances.set(cacheKey, db);
8039
8347
  return db;
8040
8348
  })();
8041
- initPromises.set(normalizedPath, promise);
8349
+ initPromises.set(cacheKey, promise);
8042
8350
  }
8043
8351
  const db = await promise;
8352
+ if (!selectedDatabasePath &&
8353
+ !process.env.VITEST &&
8354
+ process.env.NODE_ENV !== "test" &&
8355
+ options.watcher !== "disabled" &&
8356
+ !watchers.has(normalizedPath) &&
8357
+ watcherStates.get(normalizedPath)?.watcher !== "scale-limited" &&
8358
+ watcherStates.get(normalizedPath)?.watcher !== "resource-limited") {
8359
+ startFileWatcher(normalizedPath, db, options.watcherFileLimit);
8360
+ }
8044
8361
  // Cold start reconciles once (above); this guard covers everything after it,
8045
8362
  // so a session whose watcher dies still answers from a verified index.
8046
8363
  if (!options.skipFreshnessGuard)
@@ -8892,7 +9209,7 @@ function reviewChangedFiles(base, repoPath, options, modified) {
8892
9209
  args.push(safeReviewRevision(base || "HEAD"));
8893
9210
  args.push("--");
8894
9211
  for (const file of runGit(repoPath, args).split("\0")) {
8895
- if (file)
9212
+ if (file && isIndexablePath(file))
8896
9213
  files.add(file);
8897
9214
  }
8898
9215
  }
@@ -8909,7 +9226,7 @@ function reviewChangedFiles(base, repoPath, options, modified) {
8909
9226
  "--exclude-standard",
8910
9227
  "-z",
8911
9228
  ]).split("\0")) {
8912
- if (file)
9229
+ if (file && isIndexablePath(file))
8913
9230
  files.add(file);
8914
9231
  }
8915
9232
  }
@@ -10222,8 +10539,32 @@ function findImportSpecifierLines(absPath, symbol) {
10222
10539
  * threaded through each operation's many return sites, so the tag cannot be
10223
10540
  * forgotten on an early return (a not-found explain, a refused rename).
10224
10541
  */
10225
- function withStaleness(engine, claimRepository) {
10542
+ function withStaleness(engine, claimRepository, openDb, openPolicy) {
10226
10543
  return {
10544
+ createCandidate(repoPath, options) {
10545
+ claimRepository(repoPath);
10546
+ return engine.createCandidate(repoPath, options);
10547
+ },
10548
+ openCandidate(candidate) {
10549
+ claimRepository(candidate.repo);
10550
+ return engine.openCandidate(candidate);
10551
+ },
10552
+ reconcileCandidate(candidate, repoPath, changes, options) {
10553
+ claimRepository(repoPath);
10554
+ return engine.reconcileCandidate(candidate, repoPath, changes, options);
10555
+ },
10556
+ auditCandidate(candidate, options) {
10557
+ claimRepository(candidate.repo);
10558
+ return engine.auditCandidate(candidate, options);
10559
+ },
10560
+ promoteCandidate(candidate) {
10561
+ claimRepository(candidate.repo);
10562
+ return engine.promoteCandidate(candidate);
10563
+ },
10564
+ discardCandidate(candidate) {
10565
+ claimRepository(candidate.repo);
10566
+ return engine.discardCandidate(candidate);
10567
+ },
10227
10568
  async explain(symbol, repoPath, detailLevel, selector) {
10228
10569
  claimRepository(repoPath);
10229
10570
  const result = await engine.explain(symbol, repoPath, detailLevel, selector);
@@ -10260,7 +10601,7 @@ function withStaleness(engine, claimRepository) {
10260
10601
  const repos = await getFederatedRepos(repoPath);
10261
10602
  const allRepos = await Promise.all(repos.map(async (repo) => ({
10262
10603
  path: repo,
10263
- db: await getOrInitDb(repo),
10604
+ db: await openDb(repo),
10264
10605
  })));
10265
10606
  const analytics = await getGraphAnalyticsSnapshot(allRepos, path.resolve(repoPath), n, options.relationKinds);
10266
10607
  result.communities = analytics.communities;
@@ -10292,9 +10633,11 @@ function withStaleness(engine, claimRepository) {
10292
10633
  result.staleness = stalenessFor(repoPath);
10293
10634
  return result;
10294
10635
  },
10295
- status(repoPath, options) {
10636
+ async status(repoPath, options) {
10296
10637
  claimRepository(repoPath);
10297
- return engine.status(repoPath, options);
10638
+ const result = await engine.status(repoPath, options);
10639
+ result.freshnessMechanism = freshnessMechanismFor(repoPath, openPolicy);
10640
+ return result;
10298
10641
  },
10299
10642
  repair(repoPath, options) {
10300
10643
  claimRepository(repoPath);
@@ -10330,15 +10673,27 @@ function withStaleness(engine, claimRepository) {
10330
10673
  },
10331
10674
  };
10332
10675
  }
10676
+ const DEFAULT_ENGINE_OPEN_POLICY = Object.freeze({ watcher: "enabled" });
10333
10677
  /** Default SQLite + Tree-Sitter engine. */
10334
- export function createEngine() {
10678
+ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
10335
10679
  const owner = Symbol("knodin-engine");
10336
10680
  const ownedRepositories = new Set();
10681
+ const explicitDatabaseKeys = new Set();
10682
+ const candidateAudits = new Map();
10683
+ const candidates = new Map();
10337
10684
  let closed = false;
10685
+ const openDb = (repoPath, options = {}) => getOrInitDb(repoPath, {
10686
+ ...options,
10687
+ watcher: options.watcher ?? openPolicy.watcher,
10688
+ watcherFileLimit: options.watcherFileLimit ?? openPolicy.watcherFileLimit,
10689
+ databasePath: options.databasePath ?? openPolicy.databasePath,
10690
+ });
10338
10691
  const claimRepository = (repoPath) => {
10339
10692
  if (closed)
10340
10693
  throw new Error("knodin engine is closed");
10341
10694
  const resolved = path.resolve(repoPath);
10695
+ if (openPolicy.databasePath)
10696
+ explicitDatabaseKeys.add(databaseCacheKey(resolved, openPolicy.databasePath));
10342
10697
  if (ownedRepositories.has(resolved))
10343
10698
  return;
10344
10699
  ownedRepositories.add(resolved);
@@ -10346,14 +10701,395 @@ export function createEngine() {
10346
10701
  owners.add(owner);
10347
10702
  repositoryEngineOwners.set(resolved, owners);
10348
10703
  };
10704
+ const closeDatabaseKey = (key) => {
10705
+ const db = dbInstances.get(key);
10706
+ if (db) {
10707
+ try {
10708
+ db.run("PRAGMA wal_checkpoint(TRUNCATE);");
10709
+ }
10710
+ finally {
10711
+ db.close();
10712
+ }
10713
+ }
10714
+ dbInstances.delete(key);
10715
+ initPromises.delete(key);
10716
+ };
10717
+ const closeCandidateDatabase = (candidate) => {
10718
+ const key = databaseCacheKey(candidate.repo, candidate.databasePath);
10719
+ closeDatabaseKey(key);
10720
+ for (const suffix of ["-wal", "-shm"])
10721
+ fs.rmSync(`${candidate.databasePath}${suffix}`, { force: true });
10722
+ };
10723
+ const candidateDigest = (candidate) => crypto.createHash("sha256").update(fs.readFileSync(candidate.databasePath)).digest("hex");
10724
+ async function repairThroughCandidate(resolved, repairKey, options) {
10725
+ let outerLease;
10726
+ let candidate;
10727
+ let candidateEngine;
10728
+ let before;
10729
+ const activeOperation = {
10730
+ promise: undefined,
10731
+ subscribers: new Set(options?.onProgress ? [options.onProgress] : []),
10732
+ controller: new AbortController(),
10733
+ };
10734
+ const abortShared = () => activeOperation.controller.abort();
10735
+ if (options?.signal?.aborted)
10736
+ abortShared();
10737
+ else
10738
+ options?.signal?.addEventListener("abort", abortShared, { once: true });
10739
+ let pendingCompleted;
10740
+ const deliver = (event) => {
10741
+ for (const subscriber of [...activeOperation.subscribers]) {
10742
+ try {
10743
+ subscriber(event);
10744
+ }
10745
+ catch {
10746
+ // Progress subscribers cannot change repair correctness.
10747
+ }
10748
+ }
10749
+ };
10750
+ const forward = (event) => {
10751
+ if (event.phase === "completed")
10752
+ pendingCompleted = event;
10753
+ else
10754
+ deliver(event);
10755
+ };
10756
+ const operation = Promise.resolve()
10757
+ .then(async () => {
10758
+ outerLease = acquireRepairLease(resolved);
10759
+ before = await engine.status(resolved, {
10760
+ audit: "deep",
10761
+ signal: activeOperation.controller.signal,
10762
+ });
10763
+ await suspendFileWatcher(resolved);
10764
+ const activePath = resolveDbPath(resolved);
10765
+ if (fs.existsSync(activePath)) {
10766
+ candidate = await engine.createCandidate(resolved, { empty: true });
10767
+ const existing = dbInstances.get(resolved);
10768
+ const active = existing ?? new Database(activePath, { readonly: true });
10769
+ try {
10770
+ // VACUUM INTO reads a transactionally consistent image, including WAL
10771
+ // pages, without checkpointing or rewriting the active graph.
10772
+ const destination = candidate.databasePath.replaceAll("'", "''");
10773
+ active.run(`VACUUM INTO '${destination}'`);
10774
+ }
10775
+ finally {
10776
+ if (!existing)
10777
+ active.close();
10778
+ }
10779
+ }
10780
+ else {
10781
+ candidate = await engine.createCandidate(resolved);
10782
+ }
10783
+ candidateEngine = createEngine({
10784
+ watcher: "disabled",
10785
+ databasePath: candidate.databasePath,
10786
+ repairLease: "disabled",
10787
+ });
10788
+ const staged = await candidateEngine.repair(resolved, {
10789
+ ...options,
10790
+ signal: activeOperation.controller.signal,
10791
+ onProgress: forward,
10792
+ });
10793
+ await candidateEngine.close();
10794
+ candidateEngine = undefined;
10795
+ if (staged.cancelled || !staged.verified) {
10796
+ await engine.discardCandidate(candidate);
10797
+ candidate = undefined;
10798
+ const after = staged.cancelled
10799
+ ? before
10800
+ : await engine.status(resolved, { audit: "deep" });
10801
+ if (pendingCompleted)
10802
+ deliver(pendingCompleted);
10803
+ return {
10804
+ ...staged,
10805
+ before,
10806
+ after,
10807
+ repaired: [],
10808
+ remaining: after.missing.files.length + after.missing.records.length,
10809
+ outstandingIssues: {
10810
+ files: after.missing.files.length,
10811
+ records: after.missing.records.length,
10812
+ total: after.missing.files.length + after.missing.records.length,
10813
+ },
10814
+ };
10815
+ }
10816
+ const audit = await engine.auditCandidate(candidate, { mode: "deep" });
10817
+ if (!audit.healthy)
10818
+ throw new Error(`repair candidate audit failed: ${audit.issues.join(", ")}`);
10819
+ await engine.promoteCandidate(candidate);
10820
+ candidate = undefined;
10821
+ const after = await engine.status(resolved, { audit: "deep" });
10822
+ if (pendingCompleted)
10823
+ deliver(pendingCompleted);
10824
+ return {
10825
+ ...staged,
10826
+ before,
10827
+ after,
10828
+ verified: after.status === "healthy",
10829
+ outstandingIssues: {
10830
+ files: after.missing.files.length,
10831
+ records: after.missing.records.length,
10832
+ total: after.missing.files.length + after.missing.records.length,
10833
+ },
10834
+ };
10835
+ })
10836
+ .catch((error) => {
10837
+ if (pendingCompleted)
10838
+ deliver({
10839
+ ...pendingCompleted,
10840
+ phase: "failed",
10841
+ message: "Repair candidate failed before promotion",
10842
+ });
10843
+ throw error;
10844
+ });
10845
+ activeOperation.promise = operation;
10846
+ repairOperations.set(repairKey, activeOperation);
10847
+ try {
10848
+ return await operation;
10849
+ }
10850
+ finally {
10851
+ if (candidateEngine)
10852
+ await candidateEngine.close();
10853
+ if (candidate && candidates.has(candidate.id))
10854
+ await engine.discardCandidate(candidate);
10855
+ outerLease?.release();
10856
+ options?.signal?.removeEventListener("abort", abortShared);
10857
+ if (options?.onProgress)
10858
+ activeOperation.subscribers.delete(options.onProgress);
10859
+ if (repairOperations.get(repairKey) === activeOperation)
10860
+ repairOperations.delete(repairKey);
10861
+ if (openPolicy.watcher === "enabled" &&
10862
+ before?.schemaVersion === KNODIN_SCHEMA_VERSION &&
10863
+ fs.existsSync(resolveDbPath(resolved))) {
10864
+ await openDb(resolved, {
10865
+ skipContentReconciliation: true,
10866
+ skipFreshnessGuard: true,
10867
+ });
10868
+ }
10869
+ }
10870
+ }
10349
10871
  // Bound to a name (rather than returned as a bare object literal) so the
10350
10872
  // `wiki` method below can call `engine.map(..., "standard")` and reuse its output
10351
10873
  // instead of duplicating community-detection logic. Safe: `wiki`'s body
10352
10874
  // only runs after this whole object has been constructed and assigned.
10353
10875
  const engine = {
10876
+ async createCandidate(repoPath, options) {
10877
+ const resolved = path.resolve(repoPath);
10878
+ recoverInterruptedPromotion(resolved);
10879
+ const candidate = allocateCandidate(resolved);
10880
+ try {
10881
+ if (options?.empty) {
10882
+ // Restore writes verified snapshot bytes here while the engine retains
10883
+ // ownership, keeping deep audit and promotion mandatory.
10884
+ }
10885
+ else if (options?.sourceDatabasePath) {
10886
+ const source = path.resolve(options.sourceDatabasePath);
10887
+ if (!fs.existsSync(source) ||
10888
+ !fs.statSync(source).isFile() ||
10889
+ fs.lstatSync(source).isSymbolicLink())
10890
+ throw new Error("candidate source database must be a regular non-symbolic-link file");
10891
+ const header = fs.readFileSync(source).subarray(0, 16).toString("binary");
10892
+ if (header !== "SQLite format 3\u0000")
10893
+ throw new Error("candidate source is not a SQLite database");
10894
+ fs.copyFileSync(source, candidate.databasePath, fs.constants.COPYFILE_EXCL);
10895
+ fs.chmodSync(candidate.databasePath, 0o600);
10896
+ }
10897
+ else {
10898
+ await getOrInitDb(resolved, {
10899
+ databasePath: candidate.databasePath,
10900
+ watcher: "disabled",
10901
+ skipContentReconciliation: true,
10902
+ skipFreshnessGuard: true,
10903
+ });
10904
+ closeCandidateDatabase(candidate);
10905
+ }
10906
+ candidates.set(candidate.id, candidate);
10907
+ return candidate;
10908
+ }
10909
+ catch (error) {
10910
+ closeCandidateDatabase(candidate);
10911
+ discardCandidateFiles(resolved, candidate);
10912
+ throw error;
10913
+ }
10914
+ },
10915
+ async openCandidate(candidate) {
10916
+ assertCandidate(candidate.repo, candidate);
10917
+ if (!candidates.has(candidate.id))
10918
+ throw new Error("candidate is not owned by this engine");
10919
+ if (!fs.existsSync(candidate.databasePath))
10920
+ throw new Error("candidate database is missing");
10921
+ const header = fs.readFileSync(candidate.databasePath).subarray(0, 16).toString("binary");
10922
+ if (header !== "SQLite format 3\u0000")
10923
+ throw new Error("candidate is not a SQLite database");
10924
+ const probe = new Database(candidate.databasePath, { readonly: true });
10925
+ try {
10926
+ const integrity = probe.query("PRAGMA quick_check").all();
10927
+ if (integrity.length !== 1 || Object.values(integrity[0] ?? {})[0] !== "ok")
10928
+ throw new Error("candidate SQLite integrity check failed");
10929
+ const schemaVersion = probe.query("PRAGMA user_version").get()?.user_version ?? 0;
10930
+ if (schemaVersion !== KNODIN_SCHEMA_VERSION)
10931
+ throw new Error(`candidate schema ${schemaVersion} is incompatible with ${KNODIN_SCHEMA_VERSION}`);
10932
+ }
10933
+ finally {
10934
+ probe.close();
10935
+ }
10936
+ await getOrInitDb(candidate.repo, {
10937
+ databasePath: candidate.databasePath,
10938
+ watcher: "disabled",
10939
+ skipContentReconciliation: true,
10940
+ skipFreshnessGuard: true,
10941
+ });
10942
+ return candidate;
10943
+ },
10944
+ async reconcileCandidate(candidate, repoPath, changes, options) {
10945
+ const resolved = path.resolve(repoPath);
10946
+ assertCandidate(resolved, candidate);
10947
+ await engine.openCandidate(candidate);
10948
+ candidateAudits.delete(candidate.id);
10949
+ const db = dbInstances.get(databaseCacheKey(resolved, candidate.databasePath));
10950
+ if (!db)
10951
+ throw new Error("candidate database did not open");
10952
+ if (!changes) {
10953
+ await indexRepo(resolved, db, options?.onProgress, options?.skipEmbeddings === true, false);
10954
+ return { reconciled: collectRepoFiles(resolved) };
10955
+ }
10956
+ const reconciled = [...new Set(changes)]
10957
+ .map((file) => path.relative(resolved, path.resolve(resolved, file)))
10958
+ .filter((file) => file !== "" && !file.startsWith("..") && !path.isAbsolute(file))
10959
+ .sort(compareBytes);
10960
+ for (const file of reconciled) {
10961
+ const absolute = path.join(resolved, file);
10962
+ if (fs.existsSync(absolute) && isIndexableSourcePath(file)) {
10963
+ await indexFile(absolute, file, resolved, db);
10964
+ recordIndexState(db, resolved, file);
10965
+ }
10966
+ else {
10967
+ db.run("BEGIN TRANSACTION;");
10968
+ try {
10969
+ deleteSymbolsForFile(db, file);
10970
+ db.run('DELETE FROM "references" WHERE callerFile = ?', [file]);
10971
+ db.run("DELETE FROM dependencies WHERE fromFile = ?", [file]);
10972
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [file]);
10973
+ db.run("COMMIT;");
10974
+ removeIndexState(db, file);
10975
+ }
10976
+ catch (error) {
10977
+ db.run("ROLLBACK;");
10978
+ throw error;
10979
+ }
10980
+ }
10981
+ }
10982
+ await refreshFlowsForApexFiles(db, resolved, reconciled);
10983
+ persistSymbolIdentities(db, resolved, reconciled);
10984
+ reconcileTypeScriptDi(db, resolved);
10985
+ await indexEmbeddings(db, resolved);
10986
+ setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
10987
+ setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
10988
+ recordFreshnessBaseline(resolved, db);
10989
+ return { reconciled };
10990
+ },
10991
+ async auditCandidate(candidate) {
10992
+ assertCandidate(candidate.repo, candidate);
10993
+ if (!candidates.has(candidate.id))
10994
+ throw new Error("candidate is not owned by this engine");
10995
+ closeCandidateDatabase(candidate);
10996
+ const issues = [];
10997
+ const db = new Database(candidate.databasePath, { readonly: true });
10998
+ try {
10999
+ const quick = db.query("PRAGMA quick_check").all();
11000
+ if (quick.length !== 1 || Object.values(quick[0] ?? {})[0] !== "ok")
11001
+ issues.push("sqlite-integrity");
11002
+ if (db.query("PRAGMA foreign_key_check").all().length > 0)
11003
+ issues.push("foreign-key-integrity");
11004
+ const schema = db.query("PRAGMA user_version").get()?.user_version ?? 0;
11005
+ if (schema !== KNODIN_SCHEMA_VERSION)
11006
+ issues.push("database-schema-version");
11007
+ const required = [
11008
+ "symbols",
11009
+ "references",
11010
+ "dependencies",
11011
+ "symbol_embeddings",
11012
+ "index_state",
11013
+ "meta",
11014
+ ];
11015
+ const tables = new Set(db
11016
+ .query("SELECT name FROM sqlite_master WHERE type='table'")
11017
+ .all()
11018
+ .map((row) => row.name));
11019
+ for (const table of required)
11020
+ if (!tables.has(table))
11021
+ issues.push(`missing-table:${table}`);
11022
+ if (tables.has("symbols") && tables.has("symbol_embeddings")) {
11023
+ const symbols = db.query("SELECT COUNT(*) count FROM symbols").get()?.count ?? 0;
11024
+ const embeddings = db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
11025
+ ?.count ?? 0;
11026
+ if (symbols !== embeddings)
11027
+ issues.push("semantic-index-incomplete");
11028
+ }
11029
+ if (tables.has("index_state")) {
11030
+ const sourceFiles = collectRepoFiles(candidate.repo);
11031
+ const sourceSet = new Set(sourceFiles);
11032
+ const indexed = db
11033
+ .query("SELECT filePath, mtimeMs, size FROM index_state")
11034
+ .all();
11035
+ const indexedSet = new Set(indexed.map((row) => row.filePath));
11036
+ if (sourceFiles.some((file) => !indexedSet.has(file)))
11037
+ issues.push("source-coverage");
11038
+ if (indexed.some((row) => !sourceSet.has(row.filePath)))
11039
+ issues.push("ineligible-index-state");
11040
+ if (indexed.some((row) => fileDriftedFromIndexState(db, candidate.repo, row.filePath)))
11041
+ issues.push("source-snapshot-drift");
11042
+ }
11043
+ }
11044
+ finally {
11045
+ db.close();
11046
+ }
11047
+ const databaseSha256 = candidateDigest(candidate);
11048
+ if (issues.length === 0)
11049
+ candidateAudits.set(candidate.id, databaseSha256);
11050
+ else
11051
+ candidateAudits.delete(candidate.id);
11052
+ return { healthy: issues.length === 0, issues: [...new Set(issues)], databaseSha256 };
11053
+ },
11054
+ async promoteCandidate(candidate) {
11055
+ assertCandidate(candidate.repo, candidate);
11056
+ const auditedDigest = candidateAudits.get(candidate.id);
11057
+ if (!auditedDigest)
11058
+ throw new Error("candidate must pass deep audit before promotion");
11059
+ const confirmation = await engine.auditCandidate(candidate, { mode: "deep" });
11060
+ if (!confirmation.healthy || confirmation.databaseSha256 !== auditedDigest)
11061
+ throw new Error("candidate or repository changed after deep audit");
11062
+ closeCandidateDatabase(candidate);
11063
+ if (candidateDigest(candidate) !== auditedDigest)
11064
+ throw new Error("candidate changed after deep audit");
11065
+ const resolved = candidate.repo;
11066
+ await suspendFileWatcher(resolved);
11067
+ const active = dbInstances.get(resolved);
11068
+ if (active) {
11069
+ active.run("PRAGMA wal_checkpoint(TRUNCATE);");
11070
+ active.close();
11071
+ }
11072
+ dbInstances.delete(resolved);
11073
+ initPromises.delete(resolved);
11074
+ for (const suffix of ["-wal", "-shm"])
11075
+ fs.rmSync(`${resolveDbPath(resolved)}${suffix}`, { force: true });
11076
+ const backupPath = promoteCandidateFile(resolved, candidate);
11077
+ candidates.delete(candidate.id);
11078
+ candidateAudits.delete(candidate.id);
11079
+ invalidateResolutionCaches(resolved);
11080
+ indexGeneration++;
11081
+ return { backupPath };
11082
+ },
11083
+ async discardCandidate(candidate) {
11084
+ assertCandidate(candidate.repo, candidate);
11085
+ closeCandidateDatabase(candidate);
11086
+ discardCandidateFiles(candidate.repo, candidate);
11087
+ candidateAudits.delete(candidate.id);
11088
+ candidates.delete(candidate.id);
11089
+ },
10354
11090
  async explain(symbol, repoPath, detailLevel = "standard", selector = {}) {
10355
11091
  const repos = await getFederatedRepos(repoPath);
10356
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
11092
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
10357
11093
  let targetRepoPath = repoPath;
10358
11094
  let primaryDef = null;
10359
11095
  const matches = [];
@@ -10518,7 +11254,7 @@ export function createEngine() {
10518
11254
  };
10519
11255
  },
10520
11256
  async review(base, repoPath, detailLevel = "standard", options = {}) {
10521
- const db = await getOrInitDb(repoPath);
11257
+ const db = await openDb(repoPath);
10522
11258
  const { modifiedFiles, changedFiles } = measurePerfPhaseSync("review_diff_discovery", () => {
10523
11259
  const modifiedFiles = parseGitDiff(base, repoPath, options);
10524
11260
  return {
@@ -10715,7 +11451,7 @@ export function createEngine() {
10715
11451
  };
10716
11452
  },
10717
11453
  async dependencyGraph(repoPath) {
10718
- const db = await getOrInitDb(repoPath);
11454
+ const db = await openDb(repoPath);
10719
11455
  const rows = db
10720
11456
  .query("SELECT fromFile, toFile, kind, COALESCE(confidence, 1.0) AS confidence, sourceEvidence FROM dependencies")
10721
11457
  .all();
@@ -10763,7 +11499,7 @@ export function createEngine() {
10763
11499
  const repos = await getFederatedRepos(repoPath);
10764
11500
  const allRepos = await Promise.all(repos.map(async (repo) => ({
10765
11501
  path: repo,
10766
- db: await getOrInitDb(repo),
11502
+ db: await openDb(repo),
10767
11503
  })));
10768
11504
  const cacheKey = JSON.stringify([
10769
11505
  resolvedRepoPath,
@@ -10846,7 +11582,7 @@ export function createEngine() {
10846
11582
  return cached.result;
10847
11583
  const computeMap = (async () => {
10848
11584
  const repos = await getFederatedRepos(repoPath);
10849
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
11585
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
10850
11586
  const formatPath = makePathFormatter(resolvedRepoPath);
10851
11587
  const analytics = await getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath);
10852
11588
  const { communities, symbolCommunity } = analytics;
@@ -11108,7 +11844,7 @@ export function createEngine() {
11108
11844
  // Minimal mode
11109
11845
  const languageOfFile = createLanguageMemo();
11110
11846
  const repos = await getFederatedRepos(repoPath);
11111
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
11847
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
11112
11848
  const minimalCommunities = await Promise.all(fullMap.communities.map(async (c) => {
11113
11849
  const files = c.files ?? [];
11114
11850
  const langs = new Map();
@@ -11157,11 +11893,13 @@ export function createEngine() {
11157
11893
  },
11158
11894
  async status(repoPath, options = {}) {
11159
11895
  const resolved = path.resolve(repoPath);
11896
+ throwIfAborted(options.signal);
11160
11897
  // Audit must not run the cold-start reconciler: otherwise status would
11161
11898
  // repair and timestamp drift before reporting it. Reuse an active DB in
11162
11899
  // long-lived processes, or open the existing local DB read-only.
11163
- const activeDb = dbInstances.get(resolved);
11164
- const diskDbPath = resolveDbPath(resolved);
11900
+ const diskDbPath = explicitDatabasePath(resolved, openPolicy.databasePath) ?? resolveDbPath(resolved);
11901
+ const statusCacheKey = databaseCacheKey(resolved, openPolicy.databasePath);
11902
+ const activeDb = dbInstances.get(statusCacheKey);
11165
11903
  const db = activeDb ??
11166
11904
  (fs.existsSync(diskDbPath) ? new Database(diskDbPath, { readonly: true }) : null);
11167
11905
  const verifiedAt = new Date().toISOString();
@@ -11222,6 +11960,7 @@ export function createEngine() {
11222
11960
  verification: { mode: "live-operation", verifiedAt },
11223
11961
  repairSteps: [],
11224
11962
  activity,
11963
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11225
11964
  };
11226
11965
  }
11227
11966
  if (!db) {
@@ -11253,15 +11992,56 @@ export function createEngine() {
11253
11992
  lastIndexedHead: "",
11254
11993
  freshness: buildFreshnessEnvelope(resolved, null, verifiedAt, "repair-needed"),
11255
11994
  verification: { mode: "deep-audit", verifiedAt },
11995
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11256
11996
  repairSteps: [
11257
11997
  "Run `knodin repair` to create the local index.",
11258
11998
  "Run `knodin status` again to verify health.",
11259
11999
  ],
11260
12000
  };
11261
12001
  }
11262
- const databaseFingerprint = databaseFileFingerprint(diskDbPath);
11263
- const cached = statusCache.get(resolved);
11264
- if (options.audit !== "deep" &&
12002
+ const databaseFingerprint = databaseFileFingerprint(diskDbPath, db);
12003
+ let effectiveAudit = options.audit ?? "cached";
12004
+ if (effectiveAudit === "adaptive") {
12005
+ let indexedFileCount = 0;
12006
+ try {
12007
+ indexedFileCount =
12008
+ db.query("SELECT COUNT(*) count FROM index_state").get()
12009
+ ?.count ?? 0;
12010
+ }
12011
+ catch {
12012
+ // A malformed schema needs the existing deep diagnostic path.
12013
+ }
12014
+ if (adaptiveStatusStrategy(indexedFileCount) === "deep") {
12015
+ effectiveAudit = "deep";
12016
+ }
12017
+ else {
12018
+ const schemaVersion = db.query("PRAGMA user_version").get()?.user_version ?? 0;
12019
+ const persisted = readPersistedStatusAudit(resolved, diskDbPath, databaseFingerprint, schemaVersion);
12020
+ if (persisted) {
12021
+ throwIfAborted(options.signal);
12022
+ const gitProbe = measurePerfPhaseSync("freshness_probe", () => gitWorkTreeProbe(resolved));
12023
+ const drift = probeDrift(resolved, db, gitProbe);
12024
+ const probeVerifiedAt = new Date().toISOString();
12025
+ const freshness = buildFreshnessEnvelope(resolved, db, probeVerifiedAt, drift.verifiable ? (drift.drifted ? "stale-working-tree" : undefined) : "unknown", gitProbe);
12026
+ if (!activeDb)
12027
+ db.close();
12028
+ const cachedResult = structuredClone(persisted.result);
12029
+ return {
12030
+ ...cachedResult,
12031
+ status: drift.verifiable && !drift.drifted ? "healthy" : "stale",
12032
+ freshness,
12033
+ verification: {
12034
+ mode: "persisted-audit",
12035
+ auditVerifiedAt: persisted.auditVerifiedAt,
12036
+ verifiedAt: probeVerifiedAt,
12037
+ },
12038
+ };
12039
+ }
12040
+ effectiveAudit = "deep";
12041
+ }
12042
+ }
12043
+ const cached = statusCache.get(statusCacheKey);
12044
+ if (effectiveAudit !== "deep" &&
11265
12045
  cached?.generation === indexGeneration &&
11266
12046
  cached.databaseFingerprint === databaseFingerprint) {
11267
12047
  const auditVerifiedAt = Date.parse(cached.result.verification.verifiedAt);
@@ -11287,6 +12067,8 @@ export function createEngine() {
11287
12067
  ...cachedResult,
11288
12068
  verification: {
11289
12069
  mode: "cached-after-freshness-probe",
12070
+ auditVerifiedAt: cached.result.verification.auditVerifiedAt ??
12071
+ cached.result.verification.verifiedAt,
11290
12072
  verifiedAt: new Date(lease.at).toISOString(),
11291
12073
  },
11292
12074
  };
@@ -11301,6 +12083,8 @@ export function createEngine() {
11301
12083
  ...cachedResult,
11302
12084
  verification: {
11303
12085
  mode: "cached-after-freshness-probe",
12086
+ auditVerifiedAt: cached.result.verification.auditVerifiedAt ??
12087
+ cached.result.verification.verifiedAt,
11304
12088
  verifiedAt,
11305
12089
  },
11306
12090
  };
@@ -11311,7 +12095,11 @@ export function createEngine() {
11311
12095
  // reports repair steps instead of hiding the database problem.
11312
12096
  }
11313
12097
  }
11314
- const collected = measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
12098
+ throwIfAborted(options.signal);
12099
+ const collected = options.signal
12100
+ ? await measurePerfPhase("status_audit", () => collectRepoFilesWithCoverageAbortable(resolved, options.signal))
12101
+ : measurePerfPhaseSync("status_audit", () => collectRepoFilesWithCoverage(resolved));
12102
+ throwIfAborted(options.signal);
11315
12103
  const sourceFiles = collected.files;
11316
12104
  const coverageSkips = buildCoverageSkips(collected.skippedByExtension, db);
11317
12105
  const schemaVersion = db.query("PRAGMA user_version").get()?.user_version ?? 0;
@@ -11373,6 +12161,7 @@ export function createEngine() {
11373
12161
  lastIndexedHead: "",
11374
12162
  freshness: buildFreshnessEnvelope(resolved, db, verifiedAt, "repair-needed"),
11375
12163
  verification: { mode: "deep-audit", verifiedAt },
12164
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11376
12165
  repairSteps: schemaVersion > KNODIN_SCHEMA_VERSION
11377
12166
  ? [
11378
12167
  "Upgrade knodin to a version that supports this local graph schema.",
@@ -11394,25 +12183,29 @@ export function createEngine() {
11394
12183
  .all()
11395
12184
  .map((r) => r.filePath));
11396
12185
  const missingFiles = sourceFiles.filter((file) => !indexed.has(file));
11397
- const staleFiles = indexedRows
11398
- .map((row) => row.filePath)
11399
- .filter((file) => !sourceFileSet.has(file) && !fs.existsSync(path.join(resolved, file)));
11400
- const ineligibleFiles = indexedRows
11401
- .map((row) => row.filePath)
11402
- .filter((file) => !sourceFileSet.has(file) && fs.existsSync(path.join(resolved, file)));
11403
- const damagedFiles = indexedRows
11404
- .filter((row) => {
11405
- if (!sourceFileSet.has(row.filePath))
11406
- return false;
12186
+ const staleFiles = [];
12187
+ const ineligibleFiles = [];
12188
+ const damagedFiles = [];
12189
+ for (let index = 0; index < indexedRows.length; index++) {
12190
+ if (options.signal && index % 256 === 0) {
12191
+ throwIfAborted(options.signal);
12192
+ await yieldToIndexEventLoop();
12193
+ }
12194
+ const row = indexedRows[index];
12195
+ const exists = fs.existsSync(path.join(resolved, row.filePath));
12196
+ if (!sourceFileSet.has(row.filePath)) {
12197
+ (exists ? ineligibleFiles : staleFiles).push(row.filePath);
12198
+ continue;
12199
+ }
11407
12200
  try {
11408
12201
  const stat = fs.statSync(path.join(resolved, row.filePath));
11409
- return stat.size !== row.size || Math.abs(stat.mtimeMs - row.mtimeMs) >= 1;
12202
+ if (stat.size !== row.size || stat.mtimeMs !== row.mtimeMs)
12203
+ damagedFiles.push(row.filePath);
11410
12204
  }
11411
12205
  catch {
11412
- return false;
12206
+ // A concurrently removed file is represented by the next audit/probe.
11413
12207
  }
11414
- })
11415
- .map((row) => row.filePath);
12208
+ }
11416
12209
  const count = (sql) => db.query(sql).get()?.count ?? 0;
11417
12210
  const orphaned = {
11418
12211
  embeddings: count(`SELECT COUNT(*) count FROM symbol_embeddings WHERE ${ORPHANED_EMBEDDING_PREDICATE}`),
@@ -11462,7 +12255,8 @@ export function createEngine() {
11462
12255
  lastIndexedHead: getMeta(db, "lastIndexedHead") ?? "",
11463
12256
  freshness,
11464
12257
  indexGeneration,
11465
- verification: { mode: "deep-audit", verifiedAt },
12258
+ verification: { mode: "deep-audit", auditVerifiedAt: verifiedAt, verifiedAt },
12259
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11466
12260
  repairSteps: needsRepair
11467
12261
  ? [
11468
12262
  "Run `knodin repair` to rebuild only missing or damaged local state.",
@@ -11470,20 +12264,31 @@ export function createEngine() {
11470
12264
  ]
11471
12265
  : [],
11472
12266
  };
11473
- if (!activeDb)
11474
- db.close();
11475
12267
  if (!needsRepair && !stale)
11476
12268
  noteFreshness(resolved, "fresh");
11477
- setBoundedCache(statusCache, resolved, {
12269
+ if (activeDb) {
12270
+ try {
12271
+ db.run("PRAGMA wal_checkpoint(PASSIVE);");
12272
+ }
12273
+ catch {
12274
+ // A concurrent reader can defer checkpointing; the WAL fingerprint remains binding.
12275
+ }
12276
+ }
12277
+ const finalDatabaseFingerprint = databaseFileFingerprint(diskDbPath, db);
12278
+ setBoundedCache(statusCache, statusCacheKey, {
11478
12279
  generation: indexGeneration,
11479
- databaseFingerprint,
12280
+ databaseFingerprint: finalDatabaseFingerprint,
11480
12281
  result: structuredClone(result),
11481
12282
  }, STATUS_CACHE_LIMIT);
12283
+ writePersistedStatusAudit(resolved, diskDbPath, finalDatabaseFingerprint, result);
12284
+ if (!activeDb)
12285
+ db.close();
11482
12286
  return result;
11483
12287
  },
11484
12288
  async repair(repoPath, options) {
11485
12289
  const resolved = path.resolve(repoPath);
11486
- const inFlight = repairOperations.get(resolved);
12290
+ const repairKey = databaseCacheKey(resolved, openPolicy.databasePath);
12291
+ const inFlight = repairOperations.get(repairKey);
11487
12292
  if (inFlight) {
11488
12293
  if (options?.onProgress)
11489
12294
  inFlight.subscribers.add(options.onProgress);
@@ -11503,7 +12308,11 @@ export function createEngine() {
11503
12308
  options?.signal?.removeEventListener("abort", abortShared);
11504
12309
  }
11505
12310
  }
11506
- invalidateResolutionCaches(resolved);
12311
+ if (!openPolicy.databasePath && !process.env.VITEST && process.env.NODE_ENV !== "test")
12312
+ return repairThroughCandidate(resolved, repairKey, options);
12313
+ const watcherStateBefore = watcherStates.get(resolved)?.watcher;
12314
+ if (!openPolicy.databasePath)
12315
+ invalidateResolutionCaches(resolved);
11507
12316
  const operationId = crypto.randomUUID();
11508
12317
  const startedAt = Date.now();
11509
12318
  let sequence = 0;
@@ -11559,17 +12368,22 @@ export function createEngine() {
11559
12368
  };
11560
12369
  const operation = Promise.resolve()
11561
12370
  .then(async () => {
11562
- repairLease = acquireRepairLease(resolved);
11563
- const cancelBoundary = () => {
11564
- if (activeOperation.controller.signal.aborted)
11565
- throw activeOperation.controller.signal;
11566
- };
12371
+ if (!openPolicy.databasePath)
12372
+ await suspendFileWatcher(resolved);
12373
+ if (openPolicy.repairLease !== "disabled")
12374
+ repairLease = acquireRepairLease(resolved);
12375
+ const cancelBoundary = () => throwIfAborted(activeOperation.controller.signal);
11567
12376
  const invalidateCommittedWork = () => {
11568
- if (committedWork && indexGeneration === generationBeforeRepair)
12377
+ if (!openPolicy.databasePath &&
12378
+ committedWork &&
12379
+ indexGeneration === generationBeforeRepair)
11569
12380
  indexGeneration++;
11570
12381
  };
11571
12382
  emitProgress("audit", 0, "Auditing repository health");
11572
- before = await engine.status(resolved);
12383
+ before = await engine.status(resolved, {
12384
+ audit: "deep",
12385
+ signal: activeOperation.controller.signal,
12386
+ });
11573
12387
  emitProgress("audit", 1, "Repository health audit completed", {
11574
12388
  phaseTotal: 1,
11575
12389
  });
@@ -11609,9 +12423,9 @@ export function createEngine() {
11609
12423
  // Repair partial current-schema databases before normal initialization,
11610
12424
  // whose reconciliation queries require these columns. ALTER preserves all
11611
12425
  // healthy index_state rows; the reconciler then refreshes their snapshots.
11612
- const diskDbPath = resolveDbPath(resolved);
12426
+ const diskDbPath = explicitDatabasePath(resolved, openPolicy.databasePath) ?? resolveDbPath(resolved);
11613
12427
  if (fs.existsSync(diskDbPath)) {
11614
- const activeDb = dbInstances.get(resolved);
12428
+ const activeDb = dbInstances.get(repairKey);
11615
12429
  const schemaDb = activeDb ?? new Database(diskDbPath);
11616
12430
  const hasIndexState = schemaDb
11617
12431
  .query("SELECT COUNT(*) count FROM sqlite_master WHERE type='table' AND name='index_state'")
@@ -11636,10 +12450,11 @@ export function createEngine() {
11636
12450
  // A preceding audit already established the exact repair set. Reuse an
11637
12451
  // active writable handle so getOrInitDb does not run the freshness guard
11638
12452
  // again and independently reconcile the same paths.
11639
- const db = dbInstances.get(resolved) ??
11640
- (await getOrInitDb(resolved, {
12453
+ const db = dbInstances.get(repairKey) ??
12454
+ (await openDb(resolved, {
11641
12455
  skipContentReconciliation: true,
11642
12456
  skipFreshnessGuard: true,
12457
+ watcher: "disabled",
11643
12458
  }));
11644
12459
  const schemaPrepared = before.schemaVersion !== KNODIN_SCHEMA_VERSION;
11645
12460
  if (schemaPrepared)
@@ -11714,7 +12529,11 @@ export function createEngine() {
11714
12529
  phaseTotal: 1,
11715
12530
  });
11716
12531
  cancelBoundary();
11717
- persistSymbolIdentities(db, resolved, inheritedPostprocessing ? undefined : repairPaths);
12532
+ const identityScope = inheritedPostprocessing ? undefined : repairPaths;
12533
+ if (options?.signal)
12534
+ await persistSymbolIdentitiesCancellable(db, resolved, identityScope, activeOperation.controller.signal);
12535
+ else
12536
+ persistSymbolIdentities(db, resolved, identityScope);
11718
12537
  if (repairAffectsTypeScriptDi)
11719
12538
  reconcileTypeScriptDi(db, resolved);
11720
12539
  committedWork = true;
@@ -11737,7 +12556,7 @@ export function createEngine() {
11737
12556
  const model = details.modelFile ? ` (${details.modelFile})` : "";
11738
12557
  emitProgress("embeddings", 0, `${message}${model}`);
11739
12558
  }
11740
- });
12559
+ }, activeOperation.controller.signal);
11741
12560
  committedWork = true;
11742
12561
  setMeta(db, "repairPostprocessingPending", "0");
11743
12562
  overallCompleted++;
@@ -11793,7 +12612,10 @@ export function createEngine() {
11793
12612
  phaseTotal: 1,
11794
12613
  });
11795
12614
  cancelBoundary();
11796
- let after = await engine.status(resolved, { audit: "deep" });
12615
+ let after = await engine.status(resolved, {
12616
+ audit: "deep",
12617
+ signal: activeOperation.controller.signal,
12618
+ });
11797
12619
  cancelBoundary();
11798
12620
  overallCompleted++;
11799
12621
  emitProgress("verification", 1, "Repair verification completed", {
@@ -11816,21 +12638,27 @@ export function createEngine() {
11816
12638
  }
11817
12639
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
11818
12640
  recordFreshnessBaseline(resolved, db);
11819
- statusCache.delete(resolved);
11820
- const candidate = await engine.status(resolved, { audit: "deep" });
12641
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
12642
+ const candidate = await engine.status(resolved, {
12643
+ audit: "deep",
12644
+ signal: activeOperation.controller.signal,
12645
+ });
11821
12646
  if (candidate.status === "healthy") {
11822
12647
  db.run("COMMIT;");
11823
12648
  after = candidate;
11824
12649
  }
11825
12650
  else {
11826
12651
  db.run("ROLLBACK;");
11827
- statusCache.delete(resolved);
11828
- after = await engine.status(resolved, { audit: "deep" });
12652
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
12653
+ after = await engine.status(resolved, {
12654
+ audit: "deep",
12655
+ signal: activeOperation.controller.signal,
12656
+ });
11829
12657
  }
11830
12658
  }
11831
12659
  catch (error) {
11832
12660
  db.run("ROLLBACK;");
11833
- statusCache.delete(resolved);
12661
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
11834
12662
  throw error;
11835
12663
  }
11836
12664
  }
@@ -11855,10 +12683,14 @@ export function createEngine() {
11855
12683
  return result;
11856
12684
  })
11857
12685
  .catch(async (error) => {
11858
- if (error === activeOperation.controller.signal) {
11859
- if (committedWork && indexGeneration === generationBeforeRepair)
12686
+ if (wasAborted(error, activeOperation.controller.signal)) {
12687
+ if (!openPolicy.databasePath &&
12688
+ committedWork &&
12689
+ indexGeneration === generationBeforeRepair)
11860
12690
  indexGeneration++;
11861
- const after = await engine.status(resolved, { audit: "deep" });
12691
+ const after = openPolicy.databasePath
12692
+ ? before
12693
+ : await engine.status(resolved, { audit: "deep" });
11862
12694
  const remaining = Math.max(0, repairPaths.length - completedFiles);
11863
12695
  emitProgress("cancelled", completedFiles, `Repair cancelled with ${remaining} file(s) remaining`, { phaseTotal: repairPaths.length });
11864
12696
  return {
@@ -11882,7 +12714,7 @@ export function createEngine() {
11882
12714
  throw error;
11883
12715
  });
11884
12716
  activeOperation.promise = operation;
11885
- repairOperations.set(resolved, activeOperation);
12717
+ repairOperations.set(repairKey, activeOperation);
11886
12718
  try {
11887
12719
  return await operation;
11888
12720
  }
@@ -11891,223 +12723,306 @@ export function createEngine() {
11891
12723
  options?.signal?.removeEventListener("abort", abortShared);
11892
12724
  if (options?.onProgress)
11893
12725
  activeOperation.subscribers.delete(options.onProgress);
11894
- if (repairOperations.get(resolved) === activeOperation)
11895
- repairOperations.delete(resolved);
12726
+ if (repairOperations.get(repairKey) === activeOperation)
12727
+ repairOperations.delete(repairKey);
12728
+ const activeDb = dbInstances.get(resolved);
12729
+ if (!openPolicy.databasePath &&
12730
+ activeDb &&
12731
+ openPolicy.watcher === "enabled" &&
12732
+ watcherStateBefore !== "scale-limited" &&
12733
+ watcherStateBefore !== "resource-limited")
12734
+ startFileWatcher(resolved, activeDb, openPolicy.watcherFileLimit);
11896
12735
  }
11897
12736
  },
11898
12737
  async index(repoPath, files, clean = false, options) {
11899
- invalidateResolutionCaches(repoPath);
11900
- const indexed = [];
11901
- const unchanged = [];
11902
- let scipReport;
11903
- let sarifReport;
11904
- const progress = createIndexProgressReporter(options?.onProgress);
11905
- progress("starting", 0, "Opening local graph database");
11906
- const db = await getOrInitDb(repoPath, {
11907
- onProgress: progress,
11908
- // The explicit lifecycle operation owns all content work. Otherwise
11909
- // getOrInitDb's freshness guard can perform a full, invisible
11910
- // reconciliation before a targeted index even starts.
11911
- skipContentReconciliation: true,
11912
- skipFreshnessGuard: true,
11913
- });
11914
- let completionMessage = "Local graph is current";
11915
- if (files && files.length > 0) {
11916
- progress("indexing-files", 0, `Checking ${files.length.toLocaleString()} requested files`, {
11917
- phaseTotal: files.length,
12738
+ const resolved = path.resolve(repoPath);
12739
+ if (clean &&
12740
+ (!files || files.length === 0) &&
12741
+ !openPolicy.databasePath &&
12742
+ !process.env.VITEST &&
12743
+ process.env.NODE_ENV !== "test") {
12744
+ const progress = createIndexProgressReporter(options?.onProgress);
12745
+ progress("starting", 0, "Creating isolated clean-index candidate");
12746
+ const candidate = await engine.createCandidate(resolved);
12747
+ let promoted = false;
12748
+ try {
12749
+ const reconciliation = await engine.reconcileCandidate(candidate, resolved, undefined, {
12750
+ onProgress: progress,
12751
+ skipEmbeddings: options?.skipEmbeddings,
12752
+ });
12753
+ progress("verifying", 0, "Deep-auditing clean-index candidate", { phaseTotal: 1 });
12754
+ const audit = await engine.auditCandidate(candidate, { mode: "deep" });
12755
+ if (!audit.healthy)
12756
+ throw new Error(`clean-index candidate audit failed: ${audit.issues.join(", ")}`);
12757
+ await engine.promoteCandidate(candidate);
12758
+ promoted = true;
12759
+ const activeDb = await openDb(resolved, {
12760
+ skipContentReconciliation: true,
12761
+ skipFreshnessGuard: true,
12762
+ watcher: openPolicy.watcher,
12763
+ });
12764
+ if (!process.env.VITEST && process.env.NODE_ENV !== "test")
12765
+ publishStructuralSnapshot(resolved, activeDb);
12766
+ const health = await engine.status(resolved, {
12767
+ audit: "deep",
12768
+ ignoreActiveOperation: true,
12769
+ });
12770
+ const issueCount = health.missing.files.length + health.missing.records.length;
12771
+ progress("verifying", 1, health.status === "healthy"
12772
+ ? "Clean-index candidate promoted and verified"
12773
+ : `Promoted graph has ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: 1 });
12774
+ progress("completed", reconciliation.reconciled.length, "Clean index completed", {
12775
+ phaseTotal: reconciliation.reconciled.length,
12776
+ });
12777
+ return {
12778
+ indexed: reconciliation.reconciled,
12779
+ unchanged: [],
12780
+ verification: {
12781
+ status: health.status,
12782
+ issueCount,
12783
+ missing: health.missing,
12784
+ verifiedAt: health.verification.verifiedAt,
12785
+ },
12786
+ };
12787
+ }
12788
+ catch (error) {
12789
+ if (!promoted && candidates.has(candidate.id))
12790
+ await engine.discardCandidate(candidate);
12791
+ if (openPolicy.watcher === "enabled" && fs.existsSync(resolveDbPath(resolved)))
12792
+ await openDb(resolved, {
12793
+ skipContentReconciliation: true,
12794
+ skipFreshnessGuard: true,
12795
+ });
12796
+ throw error;
12797
+ }
12798
+ }
12799
+ const watcherStateBefore = watcherStates.get(resolved)?.watcher;
12800
+ if (!openPolicy.databasePath)
12801
+ await suspendFileWatcher(resolved);
12802
+ try {
12803
+ invalidateResolutionCaches(repoPath);
12804
+ const indexed = [];
12805
+ const unchanged = [];
12806
+ let scipReport;
12807
+ let sarifReport;
12808
+ const progress = createIndexProgressReporter(options?.onProgress);
12809
+ progress("starting", 0, "Opening local graph database");
12810
+ const db = await openDb(repoPath, {
12811
+ onProgress: progress,
12812
+ watcher: "disabled",
12813
+ // The explicit lifecycle operation owns all content work. Otherwise
12814
+ // getOrInitDb's freshness guard can perform a full, invisible
12815
+ // reconciliation before a targeted index even starts.
12816
+ skipContentReconciliation: true,
12817
+ skipFreshnessGuard: true,
11918
12818
  });
11919
- for (const [fileIndex, file] of files.entries()) {
11920
- const relativePath = path.isAbsolute(file) ? path.relative(repoPath, file) : file;
11921
- const absPath = path.resolve(repoPath, relativePath);
11922
- const needsUpdate = clean || fileDriftedFromIndexState(db, repoPath, relativePath);
11923
- if (!needsUpdate) {
11924
- unchanged.push(relativePath);
11925
- }
11926
- else if (fs.existsSync(absPath)) {
11927
- await indexFile(absPath, relativePath, repoPath, db);
11928
- recordIndexState(db, repoPath, relativePath);
11929
- }
11930
- else {
11931
- // File was deleted
11932
- db.run("BEGIN TRANSACTION;");
11933
- try {
11934
- deleteSymbolsForFile(db, relativePath);
11935
- db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
11936
- db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
11937
- db.run("DELETE FROM mcp_tools WHERE filePath = ?", [relativePath]);
11938
- db.run("COMMIT;");
12819
+ let completionMessage = "Local graph is current";
12820
+ if (files && files.length > 0) {
12821
+ progress("indexing-files", 0, `Checking ${files.length.toLocaleString()} requested files`, {
12822
+ phaseTotal: files.length,
12823
+ });
12824
+ for (const [fileIndex, file] of files.entries()) {
12825
+ const relativePath = path.isAbsolute(file) ? path.relative(repoPath, file) : file;
12826
+ const absPath = path.resolve(repoPath, relativePath);
12827
+ const needsUpdate = clean || fileDriftedFromIndexState(db, repoPath, relativePath);
12828
+ if (!needsUpdate) {
12829
+ unchanged.push(relativePath);
11939
12830
  }
11940
- catch (e) {
11941
- db.run("ROLLBACK;");
11942
- console.error(`Error deleting file from index: ${relativePath}`, e);
12831
+ else if (fs.existsSync(absPath)) {
12832
+ await indexFile(absPath, relativePath, repoPath, db);
12833
+ recordIndexState(db, repoPath, relativePath);
11943
12834
  }
11944
- removeIndexState(db, relativePath);
11945
- }
11946
- if (needsUpdate)
11947
- indexed.push(relativePath);
11948
- progress("indexing-files", fileIndex + 1, `Checking ${files.length.toLocaleString()} requested files`, { phaseTotal: files.length });
11949
- }
11950
- const refreshedFlows = await refreshFlowsForApexFiles(db, repoPath, indexed);
11951
- for (const flow of refreshedFlows)
11952
- if (!indexed.includes(flow))
11953
- indexed.push(flow);
11954
- if (indexed.length > 0) {
11955
- progress("finalizing", 0, "Refreshing identities and semantic embeddings");
11956
- persistSymbolIdentities(db, repoPath, indexed);
11957
- reconcileTypeScriptDi(db, repoPath);
11958
- await indexEmbeddings(db, repoPath, progress);
11959
- indexGeneration++;
11960
- }
11961
- const alreadyCurrent = unchanged.length === 1
11962
- ? "1 requested file already current"
11963
- : `${unchanged.length.toLocaleString()} requested files already current`;
11964
- completionMessage =
11965
- indexed.length === 0
11966
- ? alreadyCurrent
11967
- : `Indexed ${indexed.length.toLocaleString()} requested file(s); ${alreadyCurrent}`;
11968
- }
11969
- else {
11970
- // Full re-index or incremental sync
11971
- let hasSymbols = false;
11972
- try {
11973
- const countResult = db
11974
- .query("SELECT COUNT(*) as count FROM symbols")
11975
- .all();
11976
- if (countResult && countResult.length > 0 && countResult[0].count > 0) {
11977
- hasSymbols = true;
12835
+ else {
12836
+ // File was deleted
12837
+ db.run("BEGIN TRANSACTION;");
12838
+ try {
12839
+ deleteSymbolsForFile(db, relativePath);
12840
+ db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
12841
+ db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
12842
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [relativePath]);
12843
+ db.run("COMMIT;");
12844
+ }
12845
+ catch (e) {
12846
+ db.run("ROLLBACK;");
12847
+ console.error(`Error deleting file from index: ${relativePath}`, e);
12848
+ }
12849
+ removeIndexState(db, relativePath);
12850
+ }
12851
+ if (needsUpdate)
12852
+ indexed.push(relativePath);
12853
+ progress("indexing-files", fileIndex + 1, `Checking ${files.length.toLocaleString()} requested files`, { phaseTotal: files.length });
12854
+ }
12855
+ const refreshedFlows = await refreshFlowsForApexFiles(db, repoPath, indexed);
12856
+ for (const flow of refreshedFlows)
12857
+ if (!indexed.includes(flow))
12858
+ indexed.push(flow);
12859
+ if (indexed.length > 0) {
12860
+ progress("finalizing", 0, "Refreshing identities and semantic embeddings");
12861
+ persistSymbolIdentities(db, repoPath, indexed);
12862
+ reconcileTypeScriptDi(db, repoPath);
12863
+ await indexEmbeddings(db, repoPath, progress);
12864
+ indexGeneration++;
11978
12865
  }
11979
- }
11980
- catch (_) { }
11981
- if (!clean && hasSymbols) {
11982
- progress("collecting-files", 0, "Checking existing local graph for changes");
11983
- await reconcileIndex(repoPath, db, progress);
12866
+ const alreadyCurrent = unchanged.length === 1
12867
+ ? "1 requested file already current"
12868
+ : `${unchanged.length.toLocaleString()} requested files already current`;
12869
+ completionMessage =
12870
+ indexed.length === 0
12871
+ ? alreadyCurrent
12872
+ : `Indexed ${indexed.length.toLocaleString()} requested file(s); ${alreadyCurrent}`;
11984
12873
  }
11985
12874
  else {
11986
- await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
11987
- }
11988
- // Reuse the same candidate set as indexing/reconciliation. This keeps
11989
- // LWC HTML/CSS bundle members visible in the response without admitting
11990
- // arbitrary web assets to the source index.
11991
- // Appended in a loop, not `push(...files)`: spreading an array as
11992
- // arguments overflows the call stack once a repository is large
11993
- // enough. Observed on a 40k-file checkout, which is well within the
11994
- // range knodin is meant to index.
11995
- for (const file of collectRepoFiles(repoPath))
11996
- indexed.push(file);
11997
- completionMessage = `${indexed.length.toLocaleString()} repository file(s) are current`;
11998
- }
11999
- if (options?.scip) {
12000
- const facts = readScipIndex(repoPath, options.scip);
12001
- const affectedFiles = persistScipFacts(db, repoPath, facts);
12002
- progress("finalizing", 0, "Refreshing SCIP identities and semantic embeddings");
12003
- await indexEmbeddings(db, repoPath, progress);
12004
- indexGeneration++;
12005
- const bounds = { ...SCIP_DEFAULT_LIMITS, ...options.scip };
12006
- scipReport = {
12007
- provenance: "scip",
12008
- inputPath: facts.inputPath,
12009
- bytes: facts.bytes,
12010
- files: facts.files,
12011
- facts: facts.facts,
12012
- languages: facts.languages,
12013
- indexers: facts.indexers,
12014
- elapsedMs: facts.elapsedMs,
12015
- bounds: {
12016
- maxBytes: bounds.maxBytes,
12017
- maxFiles: bounds.maxFiles,
12018
- maxFacts: bounds.maxFacts,
12019
- timeoutMs: bounds.timeoutMs,
12020
- },
12021
- };
12022
- for (const file of affectedFiles)
12023
- if (!indexed.includes(file))
12875
+ // Full re-index or incremental sync
12876
+ let hasSymbols = false;
12877
+ try {
12878
+ const countResult = db
12879
+ .query("SELECT COUNT(*) as count FROM symbols")
12880
+ .all();
12881
+ if (countResult && countResult.length > 0 && countResult[0].count > 0) {
12882
+ hasSymbols = true;
12883
+ }
12884
+ }
12885
+ catch (_) { }
12886
+ if (!clean && hasSymbols) {
12887
+ progress("collecting-files", 0, "Checking existing local graph for changes");
12888
+ await reconcileIndex(repoPath, db, progress);
12889
+ }
12890
+ else {
12891
+ await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
12892
+ }
12893
+ // Reuse the same candidate set as indexing/reconciliation. This keeps
12894
+ // LWC HTML/CSS bundle members visible in the response without admitting
12895
+ // arbitrary web assets to the source index.
12896
+ // Appended in a loop, not `push(...files)`: spreading an array as
12897
+ // arguments overflows the call stack once a repository is large
12898
+ // enough. Observed on a 40k-file checkout, which is well within the
12899
+ // range knodin is meant to index.
12900
+ for (const file of collectRepoFiles(repoPath))
12024
12901
  indexed.push(file);
12025
- }
12026
- if (options?.sarif) {
12027
- const facts = readSarifLog(repoPath, options.sarif);
12028
- persistSarifFindings(db, facts);
12029
- // Findings are not graph facts, so no re-embedding and no generation
12030
- // bump: the graph is unchanged and existing answers stay valid.
12031
- const bounds = { ...SARIF_DEFAULT_LIMITS, ...options.sarif };
12032
- sarifReport = {
12033
- provenance: "sarif",
12034
- inputPath: facts.inputPath,
12035
- bytes: facts.bytes,
12036
- tools: facts.tools,
12037
- rules: facts.rules,
12038
- files: facts.files,
12039
- findings: facts.findings.length,
12040
- skippedUnresolved: facts.skippedUnresolved,
12041
- elapsedMs: facts.elapsedMs,
12042
- bounds: {
12043
- maxBytes: bounds.maxBytes,
12044
- maxFindings: bounds.maxFindings,
12045
- timeoutMs: bounds.timeoutMs,
12046
- },
12047
- };
12048
- }
12049
- progress("verifying", 0, "Verifying local graph health", {
12050
- phaseTotal: 1,
12051
- });
12052
- let health = await engine.status(repoPath, {
12053
- audit: "deep",
12054
- ignoreActiveOperation: true,
12055
- });
12056
- const contentVerified = health.coverage.sourceFiles === health.coverage.indexedFiles &&
12057
- health.missing.files.length === 0 &&
12058
- health.missing.records.length === 0 &&
12059
- Object.values(health.orphaned).every((count) => count === 0);
12060
- if (contentVerified &&
12061
- (health.freshness.state === "stale-head" || health.freshness.state === "unknown")) {
12062
- setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
12063
- setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
12064
- recordFreshnessBaseline(repoPath, db);
12065
- statusCache.delete(path.resolve(repoPath));
12066
- health = await engine.status(repoPath, {
12902
+ completionMessage = `${indexed.length.toLocaleString()} repository file(s) are current`;
12903
+ }
12904
+ if (options?.scip) {
12905
+ const facts = readScipIndex(repoPath, options.scip);
12906
+ const affectedFiles = persistScipFacts(db, repoPath, facts);
12907
+ progress("finalizing", 0, "Refreshing SCIP identities and semantic embeddings");
12908
+ await indexEmbeddings(db, repoPath, progress);
12909
+ indexGeneration++;
12910
+ const bounds = { ...SCIP_DEFAULT_LIMITS, ...options.scip };
12911
+ scipReport = {
12912
+ provenance: "scip",
12913
+ inputPath: facts.inputPath,
12914
+ bytes: facts.bytes,
12915
+ files: facts.files,
12916
+ facts: facts.facts,
12917
+ languages: facts.languages,
12918
+ indexers: facts.indexers,
12919
+ elapsedMs: facts.elapsedMs,
12920
+ bounds: {
12921
+ maxBytes: bounds.maxBytes,
12922
+ maxFiles: bounds.maxFiles,
12923
+ maxFacts: bounds.maxFacts,
12924
+ timeoutMs: bounds.timeoutMs,
12925
+ },
12926
+ };
12927
+ for (const file of affectedFiles)
12928
+ if (!indexed.includes(file))
12929
+ indexed.push(file);
12930
+ }
12931
+ if (options?.sarif) {
12932
+ const facts = readSarifLog(repoPath, options.sarif);
12933
+ persistSarifFindings(db, facts);
12934
+ // Findings are not graph facts, so no re-embedding and no generation
12935
+ // bump: the graph is unchanged and existing answers stay valid.
12936
+ const bounds = { ...SARIF_DEFAULT_LIMITS, ...options.sarif };
12937
+ sarifReport = {
12938
+ provenance: "sarif",
12939
+ inputPath: facts.inputPath,
12940
+ bytes: facts.bytes,
12941
+ tools: facts.tools,
12942
+ rules: facts.rules,
12943
+ files: facts.files,
12944
+ findings: facts.findings.length,
12945
+ skippedUnresolved: facts.skippedUnresolved,
12946
+ elapsedMs: facts.elapsedMs,
12947
+ bounds: {
12948
+ maxBytes: bounds.maxBytes,
12949
+ maxFindings: bounds.maxFindings,
12950
+ timeoutMs: bounds.timeoutMs,
12951
+ },
12952
+ };
12953
+ }
12954
+ progress("verifying", 0, "Verifying local graph health", {
12955
+ phaseTotal: 1,
12956
+ });
12957
+ let health = await engine.status(repoPath, {
12067
12958
  audit: "deep",
12068
12959
  ignoreActiveOperation: true,
12069
12960
  });
12070
- }
12071
- // The postcondition belongs to this index result. Do not turn the next
12072
- // explicit `status` call into a cache hit against an internal audit.
12073
- statusCache.delete(path.resolve(repoPath));
12074
- const issueCount = health.missing.files.length + health.missing.records.length;
12075
- if (health.status === "healthy") {
12076
- // Explicit indexing counts as a successful reconciliation only after
12077
- // the audit proves that no requested or unrequested drift remains.
12078
- if (files && files.length > 0) {
12961
+ const contentVerified = health.coverage.sourceFiles === health.coverage.indexedFiles &&
12962
+ health.missing.files.length === 0 &&
12963
+ health.missing.records.length === 0 &&
12964
+ Object.values(health.orphaned).every((count) => count === 0);
12965
+ if (contentVerified &&
12966
+ (health.freshness.state === "stale-head" || health.freshness.state === "unknown")) {
12079
12967
  setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
12080
12968
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
12081
12969
  recordFreshnessBaseline(repoPath, db);
12970
+ statusCache.delete(databaseCacheKey(repoPath, openPolicy.databasePath));
12971
+ health = await engine.status(repoPath, {
12972
+ audit: "deep",
12973
+ ignoreActiveOperation: true,
12974
+ });
12975
+ }
12976
+ // The postcondition belongs to this index result. Do not turn the next
12977
+ // explicit `status` call into a cache hit against an internal audit.
12978
+ statusCache.delete(databaseCacheKey(repoPath, openPolicy.databasePath));
12979
+ const issueCount = health.missing.files.length + health.missing.records.length;
12980
+ if (health.status === "healthy") {
12981
+ // Explicit indexing counts as a successful reconciliation only after
12982
+ // the audit proves that no requested or unrequested drift remains.
12983
+ if (files && files.length > 0) {
12984
+ setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
12985
+ setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
12986
+ recordFreshnessBaseline(repoPath, db);
12987
+ }
12988
+ noteFreshness(repoPath, "fresh");
12989
+ // Test engines use an in-memory graph and must not create untracked
12990
+ // repository artifacts that pollute Git-diff/review fixtures.
12991
+ if (!process.env.VITEST && process.env.NODE_ENV !== "test")
12992
+ publishStructuralSnapshot(repoPath, db);
12082
12993
  }
12083
- noteFreshness(repoPath, "fresh");
12084
- // Test engines use an in-memory graph and must not create untracked
12085
- // repository artifacts that pollute Git-diff/review fixtures.
12086
- if (!process.env.VITEST && process.env.NODE_ENV !== "test")
12087
- publishStructuralSnapshot(repoPath, db);
12994
+ else {
12995
+ freshnessProbes.delete(path.resolve(repoPath));
12996
+ }
12997
+ progress("verifying", 1, health.status === "healthy"
12998
+ ? "Local graph health verified"
12999
+ : `Local graph still has ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: 1 });
13000
+ const completed = files?.length ?? indexed.length;
13001
+ progress("completed", completed, health.status === "healthy"
13002
+ ? completionMessage
13003
+ : `${completionMessage}; verification found ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: completed });
13004
+ return {
13005
+ indexed,
13006
+ unchanged,
13007
+ scip: scipReport,
13008
+ sarif: sarifReport,
13009
+ verification: {
13010
+ status: health.status,
13011
+ issueCount,
13012
+ missing: health.missing,
13013
+ verifiedAt: health.verification.verifiedAt,
13014
+ },
13015
+ };
13016
+ }
13017
+ finally {
13018
+ const activeDb = dbInstances.get(resolved);
13019
+ if (!openPolicy.databasePath &&
13020
+ activeDb &&
13021
+ openPolicy.watcher === "enabled" &&
13022
+ watcherStateBefore !== "scale-limited" &&
13023
+ watcherStateBefore !== "resource-limited")
13024
+ startFileWatcher(resolved, activeDb, openPolicy.watcherFileLimit);
12088
13025
  }
12089
- else {
12090
- freshnessProbes.delete(path.resolve(repoPath));
12091
- }
12092
- progress("verifying", 1, health.status === "healthy"
12093
- ? "Local graph health verified"
12094
- : `Local graph still has ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: 1 });
12095
- const completed = files?.length ?? indexed.length;
12096
- progress("completed", completed, health.status === "healthy"
12097
- ? completionMessage
12098
- : `${completionMessage}; verification found ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: completed });
12099
- return {
12100
- indexed,
12101
- unchanged,
12102
- scip: scipReport,
12103
- sarif: sarifReport,
12104
- verification: {
12105
- status: health.status,
12106
- issueCount,
12107
- missing: health.missing,
12108
- verifiedAt: health.verification.verifiedAt,
12109
- },
12110
- };
12111
13026
  },
12112
13027
  async search(query, repoPath, limit, options = {}) {
12113
13028
  const resolvedRepoPath = path.resolve(repoPath);
@@ -12117,7 +13032,7 @@ export function createEngine() {
12117
13032
  if (options.testScope && !["all", "test", "production"].includes(options.testScope))
12118
13033
  throw new Error("knodin search: invalid testScope");
12119
13034
  const repos = options.federate === false ? [resolvedRepoPath] : await getFederatedRepos(repoPath);
12120
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
13035
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
12121
13036
  // Computed before any retrieval route runs, so every exit path can report
12122
13037
  // it: a short result list caused by missing embeddings must not be
12123
13038
  // indistinguishable from a short list caused by the query.
@@ -12569,8 +13484,21 @@ export function createEngine() {
12569
13484
  pattern === "lsp_implementations") {
12570
13485
  return await runReadOnlyLspQuery(pattern, target, path.resolve(repoPath));
12571
13486
  }
13487
+ if (pattern === "file_metrics") {
13488
+ const fileMetrics = collectFileMetrics(path.resolve(repoPath), {
13489
+ base: options.base,
13490
+ testScope: options.testScope,
13491
+ });
13492
+ return {
13493
+ pattern,
13494
+ target: "",
13495
+ count: fileMetrics.files.length,
13496
+ results: [],
13497
+ fileMetrics,
13498
+ };
13499
+ }
12572
13500
  const repos = await getFederatedRepos(repoPath);
12573
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
13501
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
12574
13502
  const resolvedRepoPath = path.resolve(repoPath);
12575
13503
  const formatPath = makePathFormatter(resolvedRepoPath);
12576
13504
  const primary = allRepos.find((r) => r.path === repoPath || r.path === resolvedRepoPath) ?? allRepos[0];
@@ -14760,7 +15688,7 @@ export function createEngine() {
14760
15688
  }
14761
15689
  },
14762
15690
  async rename(oldName, newName, repoPath, apply = false, verify = true, selector = {}) {
14763
- const db = await getOrInitDb(repoPath);
15691
+ const db = await openDb(repoPath);
14764
15692
  const resolvedRepoPath = path.resolve(repoPath);
14765
15693
  const resolution = resolveSymbolRows(db, resolvedRepoPath, oldName, selector);
14766
15694
  if (resolution.ambiguity)
@@ -15069,26 +15997,37 @@ export function createEngine() {
15069
15997
  db.close();
15070
15998
  dbInstances.delete(repo);
15071
15999
  initPromises.delete(repo);
16000
+ watcherStates.delete(repo);
16001
+ }
16002
+ for (const key of explicitDatabaseKeys) {
16003
+ const db = dbInstances.get(key);
16004
+ if (db)
16005
+ db.close();
16006
+ dbInstances.delete(key);
16007
+ initPromises.delete(key);
16008
+ statusCache.delete(key);
16009
+ }
16010
+ if (repositoryEngineOwners.size === 0) {
16011
+ freshnessProbes.clear();
16012
+ mapCache.clear();
16013
+ minimalMapCache.clear();
16014
+ graphAnalyticsCache.clear();
16015
+ architectureMetadataCache.clear();
16016
+ architectureOverviewCache.clear();
16017
+ traversalSnapshotCache.clear();
16018
+ statusCache.clear();
16019
+ flowsCache.clear();
16020
+ fileToFlowLookupCache.clear();
16021
+ clearGitHistorySignalCache();
16022
+ annIndexCache.clear();
16023
+ searchMetadataCache.clear();
16024
+ searchCommunityLookupCache.clear();
16025
+ resourceCorpusCache.clear();
16026
+ resourceReachabilityCache.clear();
16027
+ packageMapCache.clear();
16028
+ reExportCache.clear();
15072
16029
  }
15073
- freshnessProbes.clear();
15074
- mapCache.clear();
15075
- minimalMapCache.clear();
15076
- graphAnalyticsCache.clear();
15077
- architectureMetadataCache.clear();
15078
- architectureOverviewCache.clear();
15079
- traversalSnapshotCache.clear();
15080
- statusCache.clear();
15081
- flowsCache.clear();
15082
- fileToFlowLookupCache.clear();
15083
- clearGitHistorySignalCache();
15084
- annIndexCache.clear();
15085
- searchMetadataCache.clear();
15086
- searchCommunityLookupCache.clear();
15087
- resourceCorpusCache.clear();
15088
- resourceReachabilityCache.clear();
15089
- packageMapCache.clear();
15090
- reExportCache.clear();
15091
16030
  },
15092
16031
  };
15093
- return withStaleness(engine, claimRepository);
16032
+ return withStaleness(engine, claimRepository, openDb, openPolicy);
15094
16033
  }