knodin 0.8.4 → 0.8.5

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 (33) hide show
  1. package/dist/bin/cli.js +262 -20
  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 +930 -262
  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/package.json +11 -3
  30. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  31. package/schemas/shared-index-config-v1.schema.json +97 -0
  32. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  33. 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",
@@ -661,7 +664,31 @@ function symbolIdentityMatches(candidate, selector) {
661
664
  const dbInstances = new Map();
662
665
  const initPromises = new Map();
663
666
  const watchers = new Map();
667
+ const watcherStates = new Map();
664
668
  const WATCH_DEBOUNCE_MS = 50;
669
+ const MAX_RECURSIVE_WATCH_FILES = 20_000;
670
+ function explicitDatabasePath(repoPath, databasePath) {
671
+ if (!databasePath)
672
+ return undefined;
673
+ const stateDir = path.resolve(resolveStateDir(repoPath));
674
+ const resolved = path.resolve(databasePath);
675
+ if (resolved === path.resolve(resolveDbPath(repoPath)))
676
+ throw new Error("knodin candidate database must be distinct from the active database");
677
+ const relative = path.relative(stateDir, resolved);
678
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
679
+ throw new Error("knodin candidate database must be a distinct path inside repository state");
680
+ }
681
+ let cursor = stateDir;
682
+ for (const segment of relative.split(path.sep)) {
683
+ cursor = path.join(cursor, segment);
684
+ if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink())
685
+ throw new Error("knodin candidate database path must not contain symbolic links");
686
+ }
687
+ return resolved;
688
+ }
689
+ function databaseCacheKey(repoPath, databasePath) {
690
+ return explicitDatabasePath(repoPath, databasePath) ?? path.resolve(repoPath);
691
+ }
665
692
  const watchQueues = new Map();
666
693
  const repositoryEngineOwners = new Map();
667
694
  /**
@@ -717,11 +744,32 @@ export async function __evictRemovedRepoState() {
717
744
  }
718
745
  dbInstances.delete(key);
719
746
  initPromises.delete(key);
747
+ watcherStates.delete(key);
748
+ }
749
+ }
750
+ function freshnessMechanismFor(repoPath, policy) {
751
+ if (policy.watcher === "disabled") {
752
+ return {
753
+ strategy: "git-probes-and-lifecycle-hooks",
754
+ watcher: "disabled",
755
+ };
756
+ }
757
+ const state = watcherStates.get(path.resolve(repoPath));
758
+ if (state?.watcher === "active") {
759
+ return {
760
+ strategy: "watcher-git-probes-and-lifecycle-hooks",
761
+ watcher: "active",
762
+ };
720
763
  }
764
+ return {
765
+ strategy: "git-probes-and-lifecycle-hooks",
766
+ watcher: state?.watcher ?? "disabled",
767
+ ...(state?.diagnostic ? { diagnostic: state.diagnostic } : {}),
768
+ };
721
769
  }
722
770
  // Bumped whenever any repo is (re)indexed, so cached community-detection results
723
771
  // below can be invalidated cheaply instead of recomputed on every call.
724
- const KNODIN_SCHEMA_VERSION = 22;
772
+ export const KNODIN_SCHEMA_VERSION = 22;
725
773
  const LAST_REBUILD_SCHEMA_VERSION = 22;
726
774
  let indexGeneration = 0;
727
775
  const resourceReachabilityCache = new Map();
@@ -7322,7 +7370,7 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
7322
7370
  return st !== null;
7323
7371
  if (!st)
7324
7372
  return true;
7325
- return Math.abs(row.mtimeMs - st.mtimeMs) >= 1.0 || row.size !== st.size;
7373
+ return row.mtimeMs !== st.mtimeMs || row.size !== st.size;
7326
7374
  }
7327
7375
  /**
7328
7376
  * Record the working-tree state the index now reflects. Called at the end of a
@@ -7462,7 +7510,7 @@ function stalenessFor(repoPath) {
7462
7510
  return freshnessProbes.get(path.resolve(repoPath))?.staleness ?? "unknown";
7463
7511
  }
7464
7512
  /** Recursively indexes all matching files within the repository. */
7465
- async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
7513
+ async function indexRepo(repoPath, db, progress, skipEmbeddings = false, publishProcessState = true) {
7466
7514
  progress?.("collecting-files", 0, "Discovering indexable files");
7467
7515
  const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath));
7468
7516
  const files = collected.files;
@@ -7508,17 +7556,26 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
7508
7556
  setMeta(db, "mcpBackfillVersion", "17");
7509
7557
  setMeta(db, COVERAGE_UNPARSED_META_KEY, JSON.stringify(Object.fromEntries(unparsedByExtension)));
7510
7558
  recordFreshnessBaseline(repoPath, db);
7511
- indexGeneration++;
7559
+ if (publishProcessState)
7560
+ indexGeneration++;
7512
7561
  }
7513
7562
  // makeWatchIgnorePredicate: single source of truth is src/engine/prune.ts
7514
7563
  // (R14) — imported above, no local copy here. chokidar v4/v5 accept only a
7515
7564
  // function/regex/literal path (no globs), which is what that shared
7516
7565
  // predicate provides.
7517
7566
  /** Starts a chokidar file watcher to incrementally update SQLite indexes in real-time. */
7518
- function startFileWatcher(repoPath, db) {
7567
+ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_FILES) {
7519
7568
  const resolvedRepoPath = path.resolve(repoPath);
7520
7569
  if (watchers.has(resolvedRepoPath))
7521
7570
  return;
7571
+ const indexedFileCount = db.query("SELECT COUNT(*) count FROM index_state").get()?.count ?? 0;
7572
+ if (indexedFileCount > watcherFileLimit) {
7573
+ watcherStates.set(resolvedRepoPath, {
7574
+ watcher: "scale-limited",
7575
+ diagnostic: "repository-too-large",
7576
+ });
7577
+ return;
7578
+ }
7522
7579
  const queue = {
7523
7580
  pending: new Set(),
7524
7581
  timer: null,
@@ -7633,21 +7690,85 @@ function startFileWatcher(repoPath, db) {
7633
7690
  watcher.on("unlink", enqueue);
7634
7691
  watcher.on("ready", () => {
7635
7692
  queue.ready = true;
7693
+ watcherStates.set(resolvedRepoPath, { watcher: "active" });
7694
+ });
7695
+ watcher.on("error", (error) => {
7696
+ const code = typeof error === "object" && error !== null && "code" in error
7697
+ ? String(error.code)
7698
+ : "";
7699
+ if (degradeWatcherForResourceLimit(resolvedRepoPath, code))
7700
+ return;
7701
+ console.error(`File watcher error for ${resolvedRepoPath}:`, error);
7636
7702
  });
7637
- watcher.on("error", (error) => console.error(`File watcher error for ${resolvedRepoPath}:`, error));
7638
7703
  watchers.set(resolvedRepoPath, watcher);
7704
+ watcherStates.set(resolvedRepoPath, { watcher: "active" });
7705
+ }
7706
+ /** Convert native watch exhaustion into a bounded, query-safe freshness fallback. */
7707
+ export function degradeWatcherForResourceLimit(repoPath, code) {
7708
+ if (code !== "EMFILE" && code !== "ENOSPC")
7709
+ return false;
7710
+ const resolved = path.resolve(repoPath);
7711
+ const queue = watchQueues.get(resolved);
7712
+ if (queue) {
7713
+ queue.closed = true;
7714
+ if (queue.timer)
7715
+ clearTimeout(queue.timer);
7716
+ queue.timer = null;
7717
+ }
7718
+ watcherStates.set(resolved, {
7719
+ watcher: "resource-limited",
7720
+ diagnostic: "file-descriptor-limit",
7721
+ });
7722
+ const watcher = watchers.get(resolved);
7723
+ if (watcher) {
7724
+ void watcher.close().finally(() => {
7725
+ watchers.delete(resolved);
7726
+ watchQueues.delete(resolved);
7727
+ });
7728
+ }
7729
+ return true;
7730
+ }
7731
+ async function suspendFileWatcher(repoPath) {
7732
+ const resolved = path.resolve(repoPath);
7733
+ const watcher = watchers.get(resolved);
7734
+ if (!watcher)
7735
+ return false;
7736
+ await watcher.close();
7737
+ watchers.delete(resolved);
7738
+ const queue = watchQueues.get(resolved);
7739
+ if (queue) {
7740
+ queue.closed = true;
7741
+ if (queue.timer)
7742
+ clearTimeout(queue.timer);
7743
+ queue.timer = null;
7744
+ await queue.drain();
7745
+ watchQueues.delete(resolved);
7746
+ }
7747
+ watcherStates.set(resolved, { watcher: "disabled" });
7748
+ return true;
7639
7749
  }
7640
7750
  /** Open and initialize SQLite connection and full repo index. */
7641
7751
  async function getOrInitDb(repoPath, options = {}) {
7642
7752
  const normalizedPath = path.resolve(repoPath);
7643
- let promise = initPromises.get(normalizedPath);
7753
+ const selectedDatabasePath = explicitDatabasePath(normalizedPath, options.databasePath);
7754
+ if (!selectedDatabasePath)
7755
+ recoverInterruptedPromotion(normalizedPath);
7756
+ const cacheKey = databaseCacheKey(normalizedPath, selectedDatabasePath);
7757
+ let promise = initPromises.get(cacheKey);
7644
7758
  if (!promise) {
7645
7759
  promise = (async () => {
7646
7760
  const isTest = process.env.VITEST || process.env.NODE_ENV === "test";
7647
7761
  let dbPath;
7648
- if (isTest) {
7762
+ if (isTest && !selectedDatabasePath) {
7649
7763
  dbPath = ":memory:";
7650
7764
  }
7765
+ else if (selectedDatabasePath) {
7766
+ await fs.promises.mkdir(path.dirname(selectedDatabasePath), {
7767
+ recursive: true,
7768
+ mode: 0o700,
7769
+ });
7770
+ dbPath = selectedDatabasePath;
7771
+ }
7651
7772
  else {
7652
7773
  const stateDir = resolveStateDir(normalizedPath);
7653
7774
  await fs.promises.mkdir(stateDir, { recursive: true });
@@ -8032,15 +8153,27 @@ async function getOrInitDb(repoPath, options = {}) {
8032
8153
  }
8033
8154
  noteFreshness(normalizedPath, reindexed > 0 ? "reconciled" : "fresh");
8034
8155
  }
8035
- if (!isTest) {
8036
- startFileWatcher(normalizedPath, db);
8156
+ if (!selectedDatabasePath && !isTest && options.watcher !== "disabled") {
8157
+ startFileWatcher(normalizedPath, db, options.watcherFileLimit);
8158
+ }
8159
+ else if (!watchers.has(normalizedPath)) {
8160
+ watcherStates.set(normalizedPath, { watcher: "disabled" });
8037
8161
  }
8038
- dbInstances.set(normalizedPath, db);
8162
+ dbInstances.set(cacheKey, db);
8039
8163
  return db;
8040
8164
  })();
8041
- initPromises.set(normalizedPath, promise);
8165
+ initPromises.set(cacheKey, promise);
8042
8166
  }
8043
8167
  const db = await promise;
8168
+ if (!selectedDatabasePath &&
8169
+ !process.env.VITEST &&
8170
+ process.env.NODE_ENV !== "test" &&
8171
+ options.watcher !== "disabled" &&
8172
+ !watchers.has(normalizedPath) &&
8173
+ watcherStates.get(normalizedPath)?.watcher !== "scale-limited" &&
8174
+ watcherStates.get(normalizedPath)?.watcher !== "resource-limited") {
8175
+ startFileWatcher(normalizedPath, db, options.watcherFileLimit);
8176
+ }
8044
8177
  // Cold start reconciles once (above); this guard covers everything after it,
8045
8178
  // so a session whose watcher dies still answers from a verified index.
8046
8179
  if (!options.skipFreshnessGuard)
@@ -10222,8 +10355,32 @@ function findImportSpecifierLines(absPath, symbol) {
10222
10355
  * threaded through each operation's many return sites, so the tag cannot be
10223
10356
  * forgotten on an early return (a not-found explain, a refused rename).
10224
10357
  */
10225
- function withStaleness(engine, claimRepository) {
10358
+ function withStaleness(engine, claimRepository, openDb, openPolicy) {
10226
10359
  return {
10360
+ createCandidate(repoPath, options) {
10361
+ claimRepository(repoPath);
10362
+ return engine.createCandidate(repoPath, options);
10363
+ },
10364
+ openCandidate(candidate) {
10365
+ claimRepository(candidate.repo);
10366
+ return engine.openCandidate(candidate);
10367
+ },
10368
+ reconcileCandidate(candidate, repoPath, changes, options) {
10369
+ claimRepository(repoPath);
10370
+ return engine.reconcileCandidate(candidate, repoPath, changes, options);
10371
+ },
10372
+ auditCandidate(candidate, options) {
10373
+ claimRepository(candidate.repo);
10374
+ return engine.auditCandidate(candidate, options);
10375
+ },
10376
+ promoteCandidate(candidate) {
10377
+ claimRepository(candidate.repo);
10378
+ return engine.promoteCandidate(candidate);
10379
+ },
10380
+ discardCandidate(candidate) {
10381
+ claimRepository(candidate.repo);
10382
+ return engine.discardCandidate(candidate);
10383
+ },
10227
10384
  async explain(symbol, repoPath, detailLevel, selector) {
10228
10385
  claimRepository(repoPath);
10229
10386
  const result = await engine.explain(symbol, repoPath, detailLevel, selector);
@@ -10260,7 +10417,7 @@ function withStaleness(engine, claimRepository) {
10260
10417
  const repos = await getFederatedRepos(repoPath);
10261
10418
  const allRepos = await Promise.all(repos.map(async (repo) => ({
10262
10419
  path: repo,
10263
- db: await getOrInitDb(repo),
10420
+ db: await openDb(repo),
10264
10421
  })));
10265
10422
  const analytics = await getGraphAnalyticsSnapshot(allRepos, path.resolve(repoPath), n, options.relationKinds);
10266
10423
  result.communities = analytics.communities;
@@ -10292,9 +10449,11 @@ function withStaleness(engine, claimRepository) {
10292
10449
  result.staleness = stalenessFor(repoPath);
10293
10450
  return result;
10294
10451
  },
10295
- status(repoPath, options) {
10452
+ async status(repoPath, options) {
10296
10453
  claimRepository(repoPath);
10297
- return engine.status(repoPath, options);
10454
+ const result = await engine.status(repoPath, options);
10455
+ result.freshnessMechanism = freshnessMechanismFor(repoPath, openPolicy);
10456
+ return result;
10298
10457
  },
10299
10458
  repair(repoPath, options) {
10300
10459
  claimRepository(repoPath);
@@ -10330,15 +10489,27 @@ function withStaleness(engine, claimRepository) {
10330
10489
  },
10331
10490
  };
10332
10491
  }
10492
+ const DEFAULT_ENGINE_OPEN_POLICY = Object.freeze({ watcher: "enabled" });
10333
10493
  /** Default SQLite + Tree-Sitter engine. */
10334
- export function createEngine() {
10494
+ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
10335
10495
  const owner = Symbol("knodin-engine");
10336
10496
  const ownedRepositories = new Set();
10497
+ const explicitDatabaseKeys = new Set();
10498
+ const candidateAudits = new Map();
10499
+ const candidates = new Map();
10337
10500
  let closed = false;
10501
+ const openDb = (repoPath, options = {}) => getOrInitDb(repoPath, {
10502
+ ...options,
10503
+ watcher: options.watcher ?? openPolicy.watcher,
10504
+ watcherFileLimit: options.watcherFileLimit ?? openPolicy.watcherFileLimit,
10505
+ databasePath: options.databasePath ?? openPolicy.databasePath,
10506
+ });
10338
10507
  const claimRepository = (repoPath) => {
10339
10508
  if (closed)
10340
10509
  throw new Error("knodin engine is closed");
10341
10510
  const resolved = path.resolve(repoPath);
10511
+ if (openPolicy.databasePath)
10512
+ explicitDatabaseKeys.add(databaseCacheKey(resolved, openPolicy.databasePath));
10342
10513
  if (ownedRepositories.has(resolved))
10343
10514
  return;
10344
10515
  ownedRepositories.add(resolved);
@@ -10346,14 +10517,386 @@ export function createEngine() {
10346
10517
  owners.add(owner);
10347
10518
  repositoryEngineOwners.set(resolved, owners);
10348
10519
  };
10520
+ const closeDatabaseKey = (key) => {
10521
+ const db = dbInstances.get(key);
10522
+ if (db) {
10523
+ try {
10524
+ db.run("PRAGMA wal_checkpoint(TRUNCATE);");
10525
+ }
10526
+ finally {
10527
+ db.close();
10528
+ }
10529
+ }
10530
+ dbInstances.delete(key);
10531
+ initPromises.delete(key);
10532
+ };
10533
+ const closeCandidateDatabase = (candidate) => {
10534
+ const key = databaseCacheKey(candidate.repo, candidate.databasePath);
10535
+ closeDatabaseKey(key);
10536
+ for (const suffix of ["-wal", "-shm"])
10537
+ fs.rmSync(`${candidate.databasePath}${suffix}`, { force: true });
10538
+ };
10539
+ const candidateDigest = (candidate) => crypto.createHash("sha256").update(fs.readFileSync(candidate.databasePath)).digest("hex");
10540
+ async function repairThroughCandidate(resolved, repairKey, options) {
10541
+ let outerLease;
10542
+ let candidate;
10543
+ let candidateEngine;
10544
+ let before;
10545
+ const activeOperation = {
10546
+ promise: undefined,
10547
+ subscribers: new Set(options?.onProgress ? [options.onProgress] : []),
10548
+ controller: new AbortController(),
10549
+ };
10550
+ const abortShared = () => activeOperation.controller.abort();
10551
+ if (options?.signal?.aborted)
10552
+ abortShared();
10553
+ else
10554
+ options?.signal?.addEventListener("abort", abortShared, { once: true });
10555
+ let pendingCompleted;
10556
+ const deliver = (event) => {
10557
+ for (const subscriber of [...activeOperation.subscribers]) {
10558
+ try {
10559
+ subscriber(event);
10560
+ }
10561
+ catch {
10562
+ // Progress subscribers cannot change repair correctness.
10563
+ }
10564
+ }
10565
+ };
10566
+ const forward = (event) => {
10567
+ if (event.phase === "completed")
10568
+ pendingCompleted = event;
10569
+ else
10570
+ deliver(event);
10571
+ };
10572
+ const operation = Promise.resolve()
10573
+ .then(async () => {
10574
+ outerLease = acquireRepairLease(resolved);
10575
+ before = await engine.status(resolved, { audit: "deep" });
10576
+ await suspendFileWatcher(resolved);
10577
+ const activePath = resolveDbPath(resolved);
10578
+ if (fs.existsSync(activePath)) {
10579
+ const active = dbInstances.get(resolved) ?? new Database(activePath);
10580
+ try {
10581
+ active.run("PRAGMA wal_checkpoint(TRUNCATE);");
10582
+ }
10583
+ finally {
10584
+ active.close();
10585
+ }
10586
+ dbInstances.delete(resolved);
10587
+ initPromises.delete(resolved);
10588
+ for (const suffix of ["-wal", "-shm"])
10589
+ fs.rmSync(`${activePath}${suffix}`, { force: true });
10590
+ }
10591
+ candidate = await engine.createCandidate(resolved, fs.existsSync(activePath) ? { sourceDatabasePath: activePath } : undefined);
10592
+ candidateEngine = createEngine({
10593
+ watcher: "disabled",
10594
+ databasePath: candidate.databasePath,
10595
+ repairLease: "disabled",
10596
+ });
10597
+ const staged = await candidateEngine.repair(resolved, {
10598
+ ...options,
10599
+ signal: activeOperation.controller.signal,
10600
+ onProgress: forward,
10601
+ });
10602
+ await candidateEngine.close();
10603
+ candidateEngine = undefined;
10604
+ if (staged.cancelled || !staged.verified) {
10605
+ await engine.discardCandidate(candidate);
10606
+ candidate = undefined;
10607
+ const after = await engine.status(resolved, { audit: "deep" });
10608
+ if (pendingCompleted)
10609
+ deliver(pendingCompleted);
10610
+ return {
10611
+ ...staged,
10612
+ before,
10613
+ after,
10614
+ repaired: [],
10615
+ remaining: after.missing.files.length + after.missing.records.length,
10616
+ outstandingIssues: {
10617
+ files: after.missing.files.length,
10618
+ records: after.missing.records.length,
10619
+ total: after.missing.files.length + after.missing.records.length,
10620
+ },
10621
+ };
10622
+ }
10623
+ const audit = await engine.auditCandidate(candidate, { mode: "deep" });
10624
+ if (!audit.healthy)
10625
+ throw new Error(`repair candidate audit failed: ${audit.issues.join(", ")}`);
10626
+ await engine.promoteCandidate(candidate);
10627
+ candidate = undefined;
10628
+ const after = await engine.status(resolved, { audit: "deep" });
10629
+ if (pendingCompleted)
10630
+ deliver(pendingCompleted);
10631
+ return {
10632
+ ...staged,
10633
+ before,
10634
+ after,
10635
+ verified: after.status === "healthy",
10636
+ outstandingIssues: {
10637
+ files: after.missing.files.length,
10638
+ records: after.missing.records.length,
10639
+ total: after.missing.files.length + after.missing.records.length,
10640
+ },
10641
+ };
10642
+ })
10643
+ .catch((error) => {
10644
+ if (pendingCompleted)
10645
+ deliver({
10646
+ ...pendingCompleted,
10647
+ phase: "failed",
10648
+ message: "Repair candidate failed before promotion",
10649
+ });
10650
+ throw error;
10651
+ });
10652
+ activeOperation.promise = operation;
10653
+ repairOperations.set(repairKey, activeOperation);
10654
+ try {
10655
+ return await operation;
10656
+ }
10657
+ finally {
10658
+ if (candidateEngine)
10659
+ await candidateEngine.close();
10660
+ if (candidate && candidates.has(candidate.id))
10661
+ await engine.discardCandidate(candidate);
10662
+ outerLease?.release();
10663
+ options?.signal?.removeEventListener("abort", abortShared);
10664
+ if (options?.onProgress)
10665
+ activeOperation.subscribers.delete(options.onProgress);
10666
+ if (repairOperations.get(repairKey) === activeOperation)
10667
+ repairOperations.delete(repairKey);
10668
+ if (openPolicy.watcher === "enabled" &&
10669
+ before?.schemaVersion === KNODIN_SCHEMA_VERSION &&
10670
+ fs.existsSync(resolveDbPath(resolved))) {
10671
+ await openDb(resolved, {
10672
+ skipContentReconciliation: true,
10673
+ skipFreshnessGuard: true,
10674
+ });
10675
+ }
10676
+ }
10677
+ }
10349
10678
  // Bound to a name (rather than returned as a bare object literal) so the
10350
10679
  // `wiki` method below can call `engine.map(..., "standard")` and reuse its output
10351
10680
  // instead of duplicating community-detection logic. Safe: `wiki`'s body
10352
10681
  // only runs after this whole object has been constructed and assigned.
10353
10682
  const engine = {
10683
+ async createCandidate(repoPath, options) {
10684
+ const resolved = path.resolve(repoPath);
10685
+ recoverInterruptedPromotion(resolved);
10686
+ const candidate = allocateCandidate(resolved);
10687
+ try {
10688
+ if (options?.empty) {
10689
+ // Restore writes verified snapshot bytes here while the engine retains
10690
+ // ownership, keeping deep audit and promotion mandatory.
10691
+ }
10692
+ else if (options?.sourceDatabasePath) {
10693
+ const source = path.resolve(options.sourceDatabasePath);
10694
+ if (!fs.existsSync(source) ||
10695
+ !fs.statSync(source).isFile() ||
10696
+ fs.lstatSync(source).isSymbolicLink())
10697
+ throw new Error("candidate source database must be a regular non-symbolic-link file");
10698
+ const header = fs.readFileSync(source).subarray(0, 16).toString("binary");
10699
+ if (header !== "SQLite format 3\u0000")
10700
+ throw new Error("candidate source is not a SQLite database");
10701
+ fs.copyFileSync(source, candidate.databasePath, fs.constants.COPYFILE_EXCL);
10702
+ fs.chmodSync(candidate.databasePath, 0o600);
10703
+ }
10704
+ else {
10705
+ await getOrInitDb(resolved, {
10706
+ databasePath: candidate.databasePath,
10707
+ watcher: "disabled",
10708
+ skipContentReconciliation: true,
10709
+ skipFreshnessGuard: true,
10710
+ });
10711
+ closeCandidateDatabase(candidate);
10712
+ }
10713
+ candidates.set(candidate.id, candidate);
10714
+ return candidate;
10715
+ }
10716
+ catch (error) {
10717
+ closeCandidateDatabase(candidate);
10718
+ discardCandidateFiles(resolved, candidate);
10719
+ throw error;
10720
+ }
10721
+ },
10722
+ async openCandidate(candidate) {
10723
+ assertCandidate(candidate.repo, candidate);
10724
+ if (!candidates.has(candidate.id))
10725
+ throw new Error("candidate is not owned by this engine");
10726
+ if (!fs.existsSync(candidate.databasePath))
10727
+ throw new Error("candidate database is missing");
10728
+ const header = fs.readFileSync(candidate.databasePath).subarray(0, 16).toString("binary");
10729
+ if (header !== "SQLite format 3\u0000")
10730
+ throw new Error("candidate is not a SQLite database");
10731
+ const probe = new Database(candidate.databasePath, { readonly: true });
10732
+ try {
10733
+ const integrity = probe.query("PRAGMA quick_check").all();
10734
+ if (integrity.length !== 1 || Object.values(integrity[0] ?? {})[0] !== "ok")
10735
+ throw new Error("candidate SQLite integrity check failed");
10736
+ const schemaVersion = probe.query("PRAGMA user_version").get()?.user_version ?? 0;
10737
+ if (schemaVersion !== KNODIN_SCHEMA_VERSION)
10738
+ throw new Error(`candidate schema ${schemaVersion} is incompatible with ${KNODIN_SCHEMA_VERSION}`);
10739
+ }
10740
+ finally {
10741
+ probe.close();
10742
+ }
10743
+ await getOrInitDb(candidate.repo, {
10744
+ databasePath: candidate.databasePath,
10745
+ watcher: "disabled",
10746
+ skipContentReconciliation: true,
10747
+ skipFreshnessGuard: true,
10748
+ });
10749
+ return candidate;
10750
+ },
10751
+ async reconcileCandidate(candidate, repoPath, changes, options) {
10752
+ const resolved = path.resolve(repoPath);
10753
+ assertCandidate(resolved, candidate);
10754
+ await engine.openCandidate(candidate);
10755
+ candidateAudits.delete(candidate.id);
10756
+ const db = dbInstances.get(databaseCacheKey(resolved, candidate.databasePath));
10757
+ if (!db)
10758
+ throw new Error("candidate database did not open");
10759
+ if (!changes) {
10760
+ await indexRepo(resolved, db, options?.onProgress, options?.skipEmbeddings === true, false);
10761
+ return { reconciled: collectRepoFiles(resolved) };
10762
+ }
10763
+ const reconciled = [...new Set(changes)]
10764
+ .map((file) => path.relative(resolved, path.resolve(resolved, file)))
10765
+ .filter((file) => file !== "" && !file.startsWith("..") && !path.isAbsolute(file))
10766
+ .sort(compareBytes);
10767
+ for (const file of reconciled) {
10768
+ const absolute = path.join(resolved, file);
10769
+ if (fs.existsSync(absolute) && isIndexableSourcePath(file)) {
10770
+ await indexFile(absolute, file, resolved, db);
10771
+ recordIndexState(db, resolved, file);
10772
+ }
10773
+ else {
10774
+ db.run("BEGIN TRANSACTION;");
10775
+ try {
10776
+ deleteSymbolsForFile(db, file);
10777
+ db.run('DELETE FROM "references" WHERE callerFile = ?', [file]);
10778
+ db.run("DELETE FROM dependencies WHERE fromFile = ?", [file]);
10779
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [file]);
10780
+ db.run("COMMIT;");
10781
+ removeIndexState(db, file);
10782
+ }
10783
+ catch (error) {
10784
+ db.run("ROLLBACK;");
10785
+ throw error;
10786
+ }
10787
+ }
10788
+ }
10789
+ await refreshFlowsForApexFiles(db, resolved, reconciled);
10790
+ persistSymbolIdentities(db, resolved, reconciled);
10791
+ reconcileTypeScriptDi(db, resolved);
10792
+ await indexEmbeddings(db, resolved);
10793
+ setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
10794
+ setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
10795
+ recordFreshnessBaseline(resolved, db);
10796
+ return { reconciled };
10797
+ },
10798
+ async auditCandidate(candidate) {
10799
+ assertCandidate(candidate.repo, candidate);
10800
+ if (!candidates.has(candidate.id))
10801
+ throw new Error("candidate is not owned by this engine");
10802
+ closeCandidateDatabase(candidate);
10803
+ const issues = [];
10804
+ const db = new Database(candidate.databasePath, { readonly: true });
10805
+ try {
10806
+ const quick = db.query("PRAGMA quick_check").all();
10807
+ if (quick.length !== 1 || Object.values(quick[0] ?? {})[0] !== "ok")
10808
+ issues.push("sqlite-integrity");
10809
+ if (db.query("PRAGMA foreign_key_check").all().length > 0)
10810
+ issues.push("foreign-key-integrity");
10811
+ const schema = db.query("PRAGMA user_version").get()?.user_version ?? 0;
10812
+ if (schema !== KNODIN_SCHEMA_VERSION)
10813
+ issues.push("database-schema-version");
10814
+ const required = [
10815
+ "symbols",
10816
+ "references",
10817
+ "dependencies",
10818
+ "symbol_embeddings",
10819
+ "index_state",
10820
+ "meta",
10821
+ ];
10822
+ const tables = new Set(db
10823
+ .query("SELECT name FROM sqlite_master WHERE type='table'")
10824
+ .all()
10825
+ .map((row) => row.name));
10826
+ for (const table of required)
10827
+ if (!tables.has(table))
10828
+ issues.push(`missing-table:${table}`);
10829
+ if (tables.has("symbols") && tables.has("symbol_embeddings")) {
10830
+ const symbols = db.query("SELECT COUNT(*) count FROM symbols").get()?.count ?? 0;
10831
+ const embeddings = db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
10832
+ ?.count ?? 0;
10833
+ if (symbols !== embeddings)
10834
+ issues.push("semantic-index-incomplete");
10835
+ }
10836
+ if (tables.has("index_state")) {
10837
+ const sourceFiles = collectRepoFiles(candidate.repo);
10838
+ const sourceSet = new Set(sourceFiles);
10839
+ const indexed = db
10840
+ .query("SELECT filePath, mtimeMs, size FROM index_state")
10841
+ .all();
10842
+ const indexedSet = new Set(indexed.map((row) => row.filePath));
10843
+ if (sourceFiles.some((file) => !indexedSet.has(file)))
10844
+ issues.push("source-coverage");
10845
+ if (indexed.some((row) => !sourceSet.has(row.filePath)))
10846
+ issues.push("ineligible-index-state");
10847
+ if (indexed.some((row) => fileDriftedFromIndexState(db, candidate.repo, row.filePath)))
10848
+ issues.push("source-snapshot-drift");
10849
+ }
10850
+ }
10851
+ finally {
10852
+ db.close();
10853
+ }
10854
+ const databaseSha256 = candidateDigest(candidate);
10855
+ if (issues.length === 0)
10856
+ candidateAudits.set(candidate.id, databaseSha256);
10857
+ else
10858
+ candidateAudits.delete(candidate.id);
10859
+ return { healthy: issues.length === 0, issues: [...new Set(issues)], databaseSha256 };
10860
+ },
10861
+ async promoteCandidate(candidate) {
10862
+ assertCandidate(candidate.repo, candidate);
10863
+ const auditedDigest = candidateAudits.get(candidate.id);
10864
+ if (!auditedDigest)
10865
+ throw new Error("candidate must pass deep audit before promotion");
10866
+ const confirmation = await engine.auditCandidate(candidate, { mode: "deep" });
10867
+ if (!confirmation.healthy || confirmation.databaseSha256 !== auditedDigest)
10868
+ throw new Error("candidate or repository changed after deep audit");
10869
+ closeCandidateDatabase(candidate);
10870
+ if (candidateDigest(candidate) !== auditedDigest)
10871
+ throw new Error("candidate changed after deep audit");
10872
+ const resolved = candidate.repo;
10873
+ await suspendFileWatcher(resolved);
10874
+ const active = dbInstances.get(resolved);
10875
+ if (active) {
10876
+ active.run("PRAGMA wal_checkpoint(TRUNCATE);");
10877
+ active.close();
10878
+ }
10879
+ dbInstances.delete(resolved);
10880
+ initPromises.delete(resolved);
10881
+ for (const suffix of ["-wal", "-shm"])
10882
+ fs.rmSync(`${resolveDbPath(resolved)}${suffix}`, { force: true });
10883
+ const backupPath = promoteCandidateFile(resolved, candidate);
10884
+ candidates.delete(candidate.id);
10885
+ candidateAudits.delete(candidate.id);
10886
+ invalidateResolutionCaches(resolved);
10887
+ indexGeneration++;
10888
+ return { backupPath };
10889
+ },
10890
+ async discardCandidate(candidate) {
10891
+ assertCandidate(candidate.repo, candidate);
10892
+ closeCandidateDatabase(candidate);
10893
+ discardCandidateFiles(candidate.repo, candidate);
10894
+ candidateAudits.delete(candidate.id);
10895
+ candidates.delete(candidate.id);
10896
+ },
10354
10897
  async explain(symbol, repoPath, detailLevel = "standard", selector = {}) {
10355
10898
  const repos = await getFederatedRepos(repoPath);
10356
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
10899
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
10357
10900
  let targetRepoPath = repoPath;
10358
10901
  let primaryDef = null;
10359
10902
  const matches = [];
@@ -10518,7 +11061,7 @@ export function createEngine() {
10518
11061
  };
10519
11062
  },
10520
11063
  async review(base, repoPath, detailLevel = "standard", options = {}) {
10521
- const db = await getOrInitDb(repoPath);
11064
+ const db = await openDb(repoPath);
10522
11065
  const { modifiedFiles, changedFiles } = measurePerfPhaseSync("review_diff_discovery", () => {
10523
11066
  const modifiedFiles = parseGitDiff(base, repoPath, options);
10524
11067
  return {
@@ -10715,7 +11258,7 @@ export function createEngine() {
10715
11258
  };
10716
11259
  },
10717
11260
  async dependencyGraph(repoPath) {
10718
- const db = await getOrInitDb(repoPath);
11261
+ const db = await openDb(repoPath);
10719
11262
  const rows = db
10720
11263
  .query("SELECT fromFile, toFile, kind, COALESCE(confidence, 1.0) AS confidence, sourceEvidence FROM dependencies")
10721
11264
  .all();
@@ -10763,7 +11306,7 @@ export function createEngine() {
10763
11306
  const repos = await getFederatedRepos(repoPath);
10764
11307
  const allRepos = await Promise.all(repos.map(async (repo) => ({
10765
11308
  path: repo,
10766
- db: await getOrInitDb(repo),
11309
+ db: await openDb(repo),
10767
11310
  })));
10768
11311
  const cacheKey = JSON.stringify([
10769
11312
  resolvedRepoPath,
@@ -10846,7 +11389,7 @@ export function createEngine() {
10846
11389
  return cached.result;
10847
11390
  const computeMap = (async () => {
10848
11391
  const repos = await getFederatedRepos(repoPath);
10849
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
11392
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
10850
11393
  const formatPath = makePathFormatter(resolvedRepoPath);
10851
11394
  const analytics = await getGraphAnalyticsSnapshot(allRepos, resolvedRepoPath);
10852
11395
  const { communities, symbolCommunity } = analytics;
@@ -11108,7 +11651,7 @@ export function createEngine() {
11108
11651
  // Minimal mode
11109
11652
  const languageOfFile = createLanguageMemo();
11110
11653
  const repos = await getFederatedRepos(repoPath);
11111
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
11654
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
11112
11655
  const minimalCommunities = await Promise.all(fullMap.communities.map(async (c) => {
11113
11656
  const files = c.files ?? [];
11114
11657
  const langs = new Map();
@@ -11160,8 +11703,9 @@ export function createEngine() {
11160
11703
  // Audit must not run the cold-start reconciler: otherwise status would
11161
11704
  // repair and timestamp drift before reporting it. Reuse an active DB in
11162
11705
  // long-lived processes, or open the existing local DB read-only.
11163
- const activeDb = dbInstances.get(resolved);
11164
- const diskDbPath = resolveDbPath(resolved);
11706
+ const diskDbPath = explicitDatabasePath(resolved, openPolicy.databasePath) ?? resolveDbPath(resolved);
11707
+ const statusCacheKey = databaseCacheKey(resolved, openPolicy.databasePath);
11708
+ const activeDb = dbInstances.get(statusCacheKey);
11165
11709
  const db = activeDb ??
11166
11710
  (fs.existsSync(diskDbPath) ? new Database(diskDbPath, { readonly: true }) : null);
11167
11711
  const verifiedAt = new Date().toISOString();
@@ -11222,6 +11766,7 @@ export function createEngine() {
11222
11766
  verification: { mode: "live-operation", verifiedAt },
11223
11767
  repairSteps: [],
11224
11768
  activity,
11769
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11225
11770
  };
11226
11771
  }
11227
11772
  if (!db) {
@@ -11253,6 +11798,7 @@ export function createEngine() {
11253
11798
  lastIndexedHead: "",
11254
11799
  freshness: buildFreshnessEnvelope(resolved, null, verifiedAt, "repair-needed"),
11255
11800
  verification: { mode: "deep-audit", verifiedAt },
11801
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11256
11802
  repairSteps: [
11257
11803
  "Run `knodin repair` to create the local index.",
11258
11804
  "Run `knodin status` again to verify health.",
@@ -11260,7 +11806,7 @@ export function createEngine() {
11260
11806
  };
11261
11807
  }
11262
11808
  const databaseFingerprint = databaseFileFingerprint(diskDbPath);
11263
- const cached = statusCache.get(resolved);
11809
+ const cached = statusCache.get(statusCacheKey);
11264
11810
  if (options.audit !== "deep" &&
11265
11811
  cached?.generation === indexGeneration &&
11266
11812
  cached.databaseFingerprint === databaseFingerprint) {
@@ -11373,6 +11919,7 @@ export function createEngine() {
11373
11919
  lastIndexedHead: "",
11374
11920
  freshness: buildFreshnessEnvelope(resolved, db, verifiedAt, "repair-needed"),
11375
11921
  verification: { mode: "deep-audit", verifiedAt },
11922
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11376
11923
  repairSteps: schemaVersion > KNODIN_SCHEMA_VERSION
11377
11924
  ? [
11378
11925
  "Upgrade knodin to a version that supports this local graph schema.",
@@ -11406,7 +11953,7 @@ export function createEngine() {
11406
11953
  return false;
11407
11954
  try {
11408
11955
  const stat = fs.statSync(path.join(resolved, row.filePath));
11409
- return stat.size !== row.size || Math.abs(stat.mtimeMs - row.mtimeMs) >= 1;
11956
+ return stat.size !== row.size || stat.mtimeMs !== row.mtimeMs;
11410
11957
  }
11411
11958
  catch {
11412
11959
  return false;
@@ -11463,6 +12010,7 @@ export function createEngine() {
11463
12010
  freshness,
11464
12011
  indexGeneration,
11465
12012
  verification: { mode: "deep-audit", verifiedAt },
12013
+ freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
11466
12014
  repairSteps: needsRepair
11467
12015
  ? [
11468
12016
  "Run `knodin repair` to rebuild only missing or damaged local state.",
@@ -11474,7 +12022,7 @@ export function createEngine() {
11474
12022
  db.close();
11475
12023
  if (!needsRepair && !stale)
11476
12024
  noteFreshness(resolved, "fresh");
11477
- setBoundedCache(statusCache, resolved, {
12025
+ setBoundedCache(statusCache, statusCacheKey, {
11478
12026
  generation: indexGeneration,
11479
12027
  databaseFingerprint,
11480
12028
  result: structuredClone(result),
@@ -11483,7 +12031,8 @@ export function createEngine() {
11483
12031
  },
11484
12032
  async repair(repoPath, options) {
11485
12033
  const resolved = path.resolve(repoPath);
11486
- const inFlight = repairOperations.get(resolved);
12034
+ const repairKey = databaseCacheKey(resolved, openPolicy.databasePath);
12035
+ const inFlight = repairOperations.get(repairKey);
11487
12036
  if (inFlight) {
11488
12037
  if (options?.onProgress)
11489
12038
  inFlight.subscribers.add(options.onProgress);
@@ -11503,7 +12052,11 @@ export function createEngine() {
11503
12052
  options?.signal?.removeEventListener("abort", abortShared);
11504
12053
  }
11505
12054
  }
11506
- invalidateResolutionCaches(resolved);
12055
+ if (!openPolicy.databasePath && !process.env.VITEST && process.env.NODE_ENV !== "test")
12056
+ return repairThroughCandidate(resolved, repairKey, options);
12057
+ const watcherStateBefore = watcherStates.get(resolved)?.watcher;
12058
+ if (!openPolicy.databasePath)
12059
+ invalidateResolutionCaches(resolved);
11507
12060
  const operationId = crypto.randomUUID();
11508
12061
  const startedAt = Date.now();
11509
12062
  let sequence = 0;
@@ -11559,13 +12112,18 @@ export function createEngine() {
11559
12112
  };
11560
12113
  const operation = Promise.resolve()
11561
12114
  .then(async () => {
11562
- repairLease = acquireRepairLease(resolved);
12115
+ if (!openPolicy.databasePath)
12116
+ await suspendFileWatcher(resolved);
12117
+ if (openPolicy.repairLease !== "disabled")
12118
+ repairLease = acquireRepairLease(resolved);
11563
12119
  const cancelBoundary = () => {
11564
12120
  if (activeOperation.controller.signal.aborted)
11565
12121
  throw activeOperation.controller.signal;
11566
12122
  };
11567
12123
  const invalidateCommittedWork = () => {
11568
- if (committedWork && indexGeneration === generationBeforeRepair)
12124
+ if (!openPolicy.databasePath &&
12125
+ committedWork &&
12126
+ indexGeneration === generationBeforeRepair)
11569
12127
  indexGeneration++;
11570
12128
  };
11571
12129
  emitProgress("audit", 0, "Auditing repository health");
@@ -11609,9 +12167,9 @@ export function createEngine() {
11609
12167
  // Repair partial current-schema databases before normal initialization,
11610
12168
  // whose reconciliation queries require these columns. ALTER preserves all
11611
12169
  // healthy index_state rows; the reconciler then refreshes their snapshots.
11612
- const diskDbPath = resolveDbPath(resolved);
12170
+ const diskDbPath = explicitDatabasePath(resolved, openPolicy.databasePath) ?? resolveDbPath(resolved);
11613
12171
  if (fs.existsSync(diskDbPath)) {
11614
- const activeDb = dbInstances.get(resolved);
12172
+ const activeDb = dbInstances.get(repairKey);
11615
12173
  const schemaDb = activeDb ?? new Database(diskDbPath);
11616
12174
  const hasIndexState = schemaDb
11617
12175
  .query("SELECT COUNT(*) count FROM sqlite_master WHERE type='table' AND name='index_state'")
@@ -11636,10 +12194,11 @@ export function createEngine() {
11636
12194
  // A preceding audit already established the exact repair set. Reuse an
11637
12195
  // active writable handle so getOrInitDb does not run the freshness guard
11638
12196
  // again and independently reconcile the same paths.
11639
- const db = dbInstances.get(resolved) ??
11640
- (await getOrInitDb(resolved, {
12197
+ const db = dbInstances.get(repairKey) ??
12198
+ (await openDb(resolved, {
11641
12199
  skipContentReconciliation: true,
11642
12200
  skipFreshnessGuard: true,
12201
+ watcher: "disabled",
11643
12202
  }));
11644
12203
  const schemaPrepared = before.schemaVersion !== KNODIN_SCHEMA_VERSION;
11645
12204
  if (schemaPrepared)
@@ -11816,7 +12375,7 @@ export function createEngine() {
11816
12375
  }
11817
12376
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
11818
12377
  recordFreshnessBaseline(resolved, db);
11819
- statusCache.delete(resolved);
12378
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
11820
12379
  const candidate = await engine.status(resolved, { audit: "deep" });
11821
12380
  if (candidate.status === "healthy") {
11822
12381
  db.run("COMMIT;");
@@ -11824,13 +12383,13 @@ export function createEngine() {
11824
12383
  }
11825
12384
  else {
11826
12385
  db.run("ROLLBACK;");
11827
- statusCache.delete(resolved);
12386
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
11828
12387
  after = await engine.status(resolved, { audit: "deep" });
11829
12388
  }
11830
12389
  }
11831
12390
  catch (error) {
11832
12391
  db.run("ROLLBACK;");
11833
- statusCache.delete(resolved);
12392
+ statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
11834
12393
  throw error;
11835
12394
  }
11836
12395
  }
@@ -11856,7 +12415,9 @@ export function createEngine() {
11856
12415
  })
11857
12416
  .catch(async (error) => {
11858
12417
  if (error === activeOperation.controller.signal) {
11859
- if (committedWork && indexGeneration === generationBeforeRepair)
12418
+ if (!openPolicy.databasePath &&
12419
+ committedWork &&
12420
+ indexGeneration === generationBeforeRepair)
11860
12421
  indexGeneration++;
11861
12422
  const after = await engine.status(resolved, { audit: "deep" });
11862
12423
  const remaining = Math.max(0, repairPaths.length - completedFiles);
@@ -11882,7 +12443,7 @@ export function createEngine() {
11882
12443
  throw error;
11883
12444
  });
11884
12445
  activeOperation.promise = operation;
11885
- repairOperations.set(resolved, activeOperation);
12446
+ repairOperations.set(repairKey, activeOperation);
11886
12447
  try {
11887
12448
  return await operation;
11888
12449
  }
@@ -11891,223 +12452,306 @@ export function createEngine() {
11891
12452
  options?.signal?.removeEventListener("abort", abortShared);
11892
12453
  if (options?.onProgress)
11893
12454
  activeOperation.subscribers.delete(options.onProgress);
11894
- if (repairOperations.get(resolved) === activeOperation)
11895
- repairOperations.delete(resolved);
12455
+ if (repairOperations.get(repairKey) === activeOperation)
12456
+ repairOperations.delete(repairKey);
12457
+ const activeDb = dbInstances.get(resolved);
12458
+ if (!openPolicy.databasePath &&
12459
+ activeDb &&
12460
+ openPolicy.watcher === "enabled" &&
12461
+ watcherStateBefore !== "scale-limited" &&
12462
+ watcherStateBefore !== "resource-limited")
12463
+ startFileWatcher(resolved, activeDb, openPolicy.watcherFileLimit);
11896
12464
  }
11897
12465
  },
11898
12466
  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,
12467
+ const resolved = path.resolve(repoPath);
12468
+ if (clean &&
12469
+ (!files || files.length === 0) &&
12470
+ !openPolicy.databasePath &&
12471
+ !process.env.VITEST &&
12472
+ process.env.NODE_ENV !== "test") {
12473
+ const progress = createIndexProgressReporter(options?.onProgress);
12474
+ progress("starting", 0, "Creating isolated clean-index candidate");
12475
+ const candidate = await engine.createCandidate(resolved);
12476
+ let promoted = false;
12477
+ try {
12478
+ const reconciliation = await engine.reconcileCandidate(candidate, resolved, undefined, {
12479
+ onProgress: progress,
12480
+ skipEmbeddings: options?.skipEmbeddings,
12481
+ });
12482
+ progress("verifying", 0, "Deep-auditing clean-index candidate", { phaseTotal: 1 });
12483
+ const audit = await engine.auditCandidate(candidate, { mode: "deep" });
12484
+ if (!audit.healthy)
12485
+ throw new Error(`clean-index candidate audit failed: ${audit.issues.join(", ")}`);
12486
+ await engine.promoteCandidate(candidate);
12487
+ promoted = true;
12488
+ const activeDb = await openDb(resolved, {
12489
+ skipContentReconciliation: true,
12490
+ skipFreshnessGuard: true,
12491
+ watcher: openPolicy.watcher,
12492
+ });
12493
+ if (!process.env.VITEST && process.env.NODE_ENV !== "test")
12494
+ publishStructuralSnapshot(resolved, activeDb);
12495
+ const health = await engine.status(resolved, {
12496
+ audit: "deep",
12497
+ ignoreActiveOperation: true,
12498
+ });
12499
+ const issueCount = health.missing.files.length + health.missing.records.length;
12500
+ progress("verifying", 1, health.status === "healthy"
12501
+ ? "Clean-index candidate promoted and verified"
12502
+ : `Promoted graph has ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: 1 });
12503
+ progress("completed", reconciliation.reconciled.length, "Clean index completed", {
12504
+ phaseTotal: reconciliation.reconciled.length,
12505
+ });
12506
+ return {
12507
+ indexed: reconciliation.reconciled,
12508
+ unchanged: [],
12509
+ verification: {
12510
+ status: health.status,
12511
+ issueCount,
12512
+ missing: health.missing,
12513
+ verifiedAt: health.verification.verifiedAt,
12514
+ },
12515
+ };
12516
+ }
12517
+ catch (error) {
12518
+ if (!promoted && candidates.has(candidate.id))
12519
+ await engine.discardCandidate(candidate);
12520
+ if (openPolicy.watcher === "enabled" && fs.existsSync(resolveDbPath(resolved)))
12521
+ await openDb(resolved, {
12522
+ skipContentReconciliation: true,
12523
+ skipFreshnessGuard: true,
12524
+ });
12525
+ throw error;
12526
+ }
12527
+ }
12528
+ const watcherStateBefore = watcherStates.get(resolved)?.watcher;
12529
+ if (!openPolicy.databasePath)
12530
+ await suspendFileWatcher(resolved);
12531
+ try {
12532
+ invalidateResolutionCaches(repoPath);
12533
+ const indexed = [];
12534
+ const unchanged = [];
12535
+ let scipReport;
12536
+ let sarifReport;
12537
+ const progress = createIndexProgressReporter(options?.onProgress);
12538
+ progress("starting", 0, "Opening local graph database");
12539
+ const db = await openDb(repoPath, {
12540
+ onProgress: progress,
12541
+ watcher: "disabled",
12542
+ // The explicit lifecycle operation owns all content work. Otherwise
12543
+ // getOrInitDb's freshness guard can perform a full, invisible
12544
+ // reconciliation before a targeted index even starts.
12545
+ skipContentReconciliation: true,
12546
+ skipFreshnessGuard: true,
11918
12547
  });
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;");
12548
+ let completionMessage = "Local graph is current";
12549
+ if (files && files.length > 0) {
12550
+ progress("indexing-files", 0, `Checking ${files.length.toLocaleString()} requested files`, {
12551
+ phaseTotal: files.length,
12552
+ });
12553
+ for (const [fileIndex, file] of files.entries()) {
12554
+ const relativePath = path.isAbsolute(file) ? path.relative(repoPath, file) : file;
12555
+ const absPath = path.resolve(repoPath, relativePath);
12556
+ const needsUpdate = clean || fileDriftedFromIndexState(db, repoPath, relativePath);
12557
+ if (!needsUpdate) {
12558
+ unchanged.push(relativePath);
11939
12559
  }
11940
- catch (e) {
11941
- db.run("ROLLBACK;");
11942
- console.error(`Error deleting file from index: ${relativePath}`, e);
12560
+ else if (fs.existsSync(absPath)) {
12561
+ await indexFile(absPath, relativePath, repoPath, db);
12562
+ recordIndexState(db, repoPath, relativePath);
11943
12563
  }
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;
12564
+ else {
12565
+ // File was deleted
12566
+ db.run("BEGIN TRANSACTION;");
12567
+ try {
12568
+ deleteSymbolsForFile(db, relativePath);
12569
+ db.run('DELETE FROM "references" WHERE callerFile = ?', [relativePath]);
12570
+ db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
12571
+ db.run("DELETE FROM mcp_tools WHERE filePath = ?", [relativePath]);
12572
+ db.run("COMMIT;");
12573
+ }
12574
+ catch (e) {
12575
+ db.run("ROLLBACK;");
12576
+ console.error(`Error deleting file from index: ${relativePath}`, e);
12577
+ }
12578
+ removeIndexState(db, relativePath);
12579
+ }
12580
+ if (needsUpdate)
12581
+ indexed.push(relativePath);
12582
+ progress("indexing-files", fileIndex + 1, `Checking ${files.length.toLocaleString()} requested files`, { phaseTotal: files.length });
12583
+ }
12584
+ const refreshedFlows = await refreshFlowsForApexFiles(db, repoPath, indexed);
12585
+ for (const flow of refreshedFlows)
12586
+ if (!indexed.includes(flow))
12587
+ indexed.push(flow);
12588
+ if (indexed.length > 0) {
12589
+ progress("finalizing", 0, "Refreshing identities and semantic embeddings");
12590
+ persistSymbolIdentities(db, repoPath, indexed);
12591
+ reconcileTypeScriptDi(db, repoPath);
12592
+ await indexEmbeddings(db, repoPath, progress);
12593
+ indexGeneration++;
11978
12594
  }
11979
- }
11980
- catch (_) { }
11981
- if (!clean && hasSymbols) {
11982
- progress("collecting-files", 0, "Checking existing local graph for changes");
11983
- await reconcileIndex(repoPath, db, progress);
12595
+ const alreadyCurrent = unchanged.length === 1
12596
+ ? "1 requested file already current"
12597
+ : `${unchanged.length.toLocaleString()} requested files already current`;
12598
+ completionMessage =
12599
+ indexed.length === 0
12600
+ ? alreadyCurrent
12601
+ : `Indexed ${indexed.length.toLocaleString()} requested file(s); ${alreadyCurrent}`;
11984
12602
  }
11985
12603
  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))
12604
+ // Full re-index or incremental sync
12605
+ let hasSymbols = false;
12606
+ try {
12607
+ const countResult = db
12608
+ .query("SELECT COUNT(*) as count FROM symbols")
12609
+ .all();
12610
+ if (countResult && countResult.length > 0 && countResult[0].count > 0) {
12611
+ hasSymbols = true;
12612
+ }
12613
+ }
12614
+ catch (_) { }
12615
+ if (!clean && hasSymbols) {
12616
+ progress("collecting-files", 0, "Checking existing local graph for changes");
12617
+ await reconcileIndex(repoPath, db, progress);
12618
+ }
12619
+ else {
12620
+ await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
12621
+ }
12622
+ // Reuse the same candidate set as indexing/reconciliation. This keeps
12623
+ // LWC HTML/CSS bundle members visible in the response without admitting
12624
+ // arbitrary web assets to the source index.
12625
+ // Appended in a loop, not `push(...files)`: spreading an array as
12626
+ // arguments overflows the call stack once a repository is large
12627
+ // enough. Observed on a 40k-file checkout, which is well within the
12628
+ // range knodin is meant to index.
12629
+ for (const file of collectRepoFiles(repoPath))
12024
12630
  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, {
12631
+ completionMessage = `${indexed.length.toLocaleString()} repository file(s) are current`;
12632
+ }
12633
+ if (options?.scip) {
12634
+ const facts = readScipIndex(repoPath, options.scip);
12635
+ const affectedFiles = persistScipFacts(db, repoPath, facts);
12636
+ progress("finalizing", 0, "Refreshing SCIP identities and semantic embeddings");
12637
+ await indexEmbeddings(db, repoPath, progress);
12638
+ indexGeneration++;
12639
+ const bounds = { ...SCIP_DEFAULT_LIMITS, ...options.scip };
12640
+ scipReport = {
12641
+ provenance: "scip",
12642
+ inputPath: facts.inputPath,
12643
+ bytes: facts.bytes,
12644
+ files: facts.files,
12645
+ facts: facts.facts,
12646
+ languages: facts.languages,
12647
+ indexers: facts.indexers,
12648
+ elapsedMs: facts.elapsedMs,
12649
+ bounds: {
12650
+ maxBytes: bounds.maxBytes,
12651
+ maxFiles: bounds.maxFiles,
12652
+ maxFacts: bounds.maxFacts,
12653
+ timeoutMs: bounds.timeoutMs,
12654
+ },
12655
+ };
12656
+ for (const file of affectedFiles)
12657
+ if (!indexed.includes(file))
12658
+ indexed.push(file);
12659
+ }
12660
+ if (options?.sarif) {
12661
+ const facts = readSarifLog(repoPath, options.sarif);
12662
+ persistSarifFindings(db, facts);
12663
+ // Findings are not graph facts, so no re-embedding and no generation
12664
+ // bump: the graph is unchanged and existing answers stay valid.
12665
+ const bounds = { ...SARIF_DEFAULT_LIMITS, ...options.sarif };
12666
+ sarifReport = {
12667
+ provenance: "sarif",
12668
+ inputPath: facts.inputPath,
12669
+ bytes: facts.bytes,
12670
+ tools: facts.tools,
12671
+ rules: facts.rules,
12672
+ files: facts.files,
12673
+ findings: facts.findings.length,
12674
+ skippedUnresolved: facts.skippedUnresolved,
12675
+ elapsedMs: facts.elapsedMs,
12676
+ bounds: {
12677
+ maxBytes: bounds.maxBytes,
12678
+ maxFindings: bounds.maxFindings,
12679
+ timeoutMs: bounds.timeoutMs,
12680
+ },
12681
+ };
12682
+ }
12683
+ progress("verifying", 0, "Verifying local graph health", {
12684
+ phaseTotal: 1,
12685
+ });
12686
+ let health = await engine.status(repoPath, {
12067
12687
  audit: "deep",
12068
12688
  ignoreActiveOperation: true,
12069
12689
  });
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) {
12690
+ const contentVerified = health.coverage.sourceFiles === health.coverage.indexedFiles &&
12691
+ health.missing.files.length === 0 &&
12692
+ health.missing.records.length === 0 &&
12693
+ Object.values(health.orphaned).every((count) => count === 0);
12694
+ if (contentVerified &&
12695
+ (health.freshness.state === "stale-head" || health.freshness.state === "unknown")) {
12079
12696
  setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
12080
12697
  setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
12081
12698
  recordFreshnessBaseline(repoPath, db);
12699
+ statusCache.delete(databaseCacheKey(repoPath, openPolicy.databasePath));
12700
+ health = await engine.status(repoPath, {
12701
+ audit: "deep",
12702
+ ignoreActiveOperation: true,
12703
+ });
12082
12704
  }
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);
12705
+ // The postcondition belongs to this index result. Do not turn the next
12706
+ // explicit `status` call into a cache hit against an internal audit.
12707
+ statusCache.delete(databaseCacheKey(repoPath, openPolicy.databasePath));
12708
+ const issueCount = health.missing.files.length + health.missing.records.length;
12709
+ if (health.status === "healthy") {
12710
+ // Explicit indexing counts as a successful reconciliation only after
12711
+ // the audit proves that no requested or unrequested drift remains.
12712
+ if (files && files.length > 0) {
12713
+ setMeta(db, "lastIndexedHead", gitHead(repoPath) ?? "");
12714
+ setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
12715
+ recordFreshnessBaseline(repoPath, db);
12716
+ }
12717
+ noteFreshness(repoPath, "fresh");
12718
+ // Test engines use an in-memory graph and must not create untracked
12719
+ // repository artifacts that pollute Git-diff/review fixtures.
12720
+ if (!process.env.VITEST && process.env.NODE_ENV !== "test")
12721
+ publishStructuralSnapshot(repoPath, db);
12722
+ }
12723
+ else {
12724
+ freshnessProbes.delete(path.resolve(repoPath));
12725
+ }
12726
+ progress("verifying", 1, health.status === "healthy"
12727
+ ? "Local graph health verified"
12728
+ : `Local graph still has ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: 1 });
12729
+ const completed = files?.length ?? indexed.length;
12730
+ progress("completed", completed, health.status === "healthy"
12731
+ ? completionMessage
12732
+ : `${completionMessage}; verification found ${issueCount.toLocaleString()} issue(s)`, { phaseTotal: completed });
12733
+ return {
12734
+ indexed,
12735
+ unchanged,
12736
+ scip: scipReport,
12737
+ sarif: sarifReport,
12738
+ verification: {
12739
+ status: health.status,
12740
+ issueCount,
12741
+ missing: health.missing,
12742
+ verifiedAt: health.verification.verifiedAt,
12743
+ },
12744
+ };
12745
+ }
12746
+ finally {
12747
+ const activeDb = dbInstances.get(resolved);
12748
+ if (!openPolicy.databasePath &&
12749
+ activeDb &&
12750
+ openPolicy.watcher === "enabled" &&
12751
+ watcherStateBefore !== "scale-limited" &&
12752
+ watcherStateBefore !== "resource-limited")
12753
+ startFileWatcher(resolved, activeDb, openPolicy.watcherFileLimit);
12088
12754
  }
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
12755
  },
12112
12756
  async search(query, repoPath, limit, options = {}) {
12113
12757
  const resolvedRepoPath = path.resolve(repoPath);
@@ -12117,7 +12761,7 @@ export function createEngine() {
12117
12761
  if (options.testScope && !["all", "test", "production"].includes(options.testScope))
12118
12762
  throw new Error("knodin search: invalid testScope");
12119
12763
  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) })));
12764
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
12121
12765
  // Computed before any retrieval route runs, so every exit path can report
12122
12766
  // it: a short result list caused by missing embeddings must not be
12123
12767
  // indistinguishable from a short list caused by the query.
@@ -12569,8 +13213,21 @@ export function createEngine() {
12569
13213
  pattern === "lsp_implementations") {
12570
13214
  return await runReadOnlyLspQuery(pattern, target, path.resolve(repoPath));
12571
13215
  }
13216
+ if (pattern === "file_metrics") {
13217
+ const fileMetrics = collectFileMetrics(path.resolve(repoPath), {
13218
+ base: options.base,
13219
+ testScope: options.testScope,
13220
+ });
13221
+ return {
13222
+ pattern,
13223
+ target: "",
13224
+ count: fileMetrics.files.length,
13225
+ results: [],
13226
+ fileMetrics,
13227
+ };
13228
+ }
12572
13229
  const repos = await getFederatedRepos(repoPath);
12573
- const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await getOrInitDb(r) })));
13230
+ const allRepos = await Promise.all(repos.map(async (r) => ({ path: r, db: await openDb(r) })));
12574
13231
  const resolvedRepoPath = path.resolve(repoPath);
12575
13232
  const formatPath = makePathFormatter(resolvedRepoPath);
12576
13233
  const primary = allRepos.find((r) => r.path === repoPath || r.path === resolvedRepoPath) ?? allRepos[0];
@@ -14760,7 +15417,7 @@ export function createEngine() {
14760
15417
  }
14761
15418
  },
14762
15419
  async rename(oldName, newName, repoPath, apply = false, verify = true, selector = {}) {
14763
- const db = await getOrInitDb(repoPath);
15420
+ const db = await openDb(repoPath);
14764
15421
  const resolvedRepoPath = path.resolve(repoPath);
14765
15422
  const resolution = resolveSymbolRows(db, resolvedRepoPath, oldName, selector);
14766
15423
  if (resolution.ambiguity)
@@ -15069,26 +15726,37 @@ export function createEngine() {
15069
15726
  db.close();
15070
15727
  dbInstances.delete(repo);
15071
15728
  initPromises.delete(repo);
15729
+ watcherStates.delete(repo);
15730
+ }
15731
+ for (const key of explicitDatabaseKeys) {
15732
+ const db = dbInstances.get(key);
15733
+ if (db)
15734
+ db.close();
15735
+ dbInstances.delete(key);
15736
+ initPromises.delete(key);
15737
+ statusCache.delete(key);
15738
+ }
15739
+ if (repositoryEngineOwners.size === 0) {
15740
+ freshnessProbes.clear();
15741
+ mapCache.clear();
15742
+ minimalMapCache.clear();
15743
+ graphAnalyticsCache.clear();
15744
+ architectureMetadataCache.clear();
15745
+ architectureOverviewCache.clear();
15746
+ traversalSnapshotCache.clear();
15747
+ statusCache.clear();
15748
+ flowsCache.clear();
15749
+ fileToFlowLookupCache.clear();
15750
+ clearGitHistorySignalCache();
15751
+ annIndexCache.clear();
15752
+ searchMetadataCache.clear();
15753
+ searchCommunityLookupCache.clear();
15754
+ resourceCorpusCache.clear();
15755
+ resourceReachabilityCache.clear();
15756
+ packageMapCache.clear();
15757
+ reExportCache.clear();
15072
15758
  }
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
15759
  },
15092
15760
  };
15093
- return withStaleness(engine, claimRepository);
15761
+ return withStaleness(engine, claimRepository, openDb, openPolicy);
15094
15762
  }