knodin 0.8.3 → 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.
- package/README.md +7 -1
- package/dist/bin/cli.js +489 -78
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +42 -1
- package/dist/src/engine/candidate-database.js +167 -0
- package/dist/src/engine/embeddings.js +8 -5
- package/dist/src/engine/index.js +930 -262
- package/dist/src/engine/prune.js +8 -0
- package/dist/src/file-metrics.js +153 -0
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/repair-lease.js +8 -5
- package/dist/src/response-budget.js +2 -1
- package/dist/src/session-telemetry.js +163 -0
- package/dist/src/shared-index/artifact.js +65 -0
- package/dist/src/shared-index/cache.js +267 -0
- package/dist/src/shared-index/compatibility.js +70 -0
- package/dist/src/shared-index/config.js +224 -0
- package/dist/src/shared-index/contract.js +117 -0
- package/dist/src/shared-index/manifest.js +71 -0
- package/dist/src/shared-index/opportunistic.js +37 -0
- package/dist/src/shared-index/overlay.js +98 -0
- package/dist/src/shared-index/provenance.js +149 -0
- package/dist/src/shared-index/publisher.js +198 -0
- package/dist/src/shared-index/restore.js +209 -0
- package/dist/src/shared-index/s3-client.js +124 -0
- package/dist/src/shared-index/schemas.js +86 -0
- package/dist/src/shared-index/selection.js +111 -0
- package/dist/src/tools/knodin-tools.js +70 -3
- package/docs/CLI.md +56 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/MCP.md +12 -0
- package/docs/SHARED-INDEX-CONTRACT.md +132 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.4.md +22 -0
- package/docs/releases/0.8.5.md +25 -0
- package/package.json +12 -3
- package/roadmap/competitive-roadmap.md +20 -0
- package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
- package/schemas/shared-index-config-v1.schema.json +97 -0
- package/schemas/shared-index-manifest-v1.schema.json +104 -0
- package/schemas/shared-index-provenance-v1.schema.json +100 -0
package/dist/src/engine/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
8162
|
+
dbInstances.set(cacheKey, db);
|
|
8039
8163
|
return db;
|
|
8040
8164
|
})();
|
|
8041
|
-
initPromises.set(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
11164
|
-
const
|
|
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(
|
|
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 ||
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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(
|
|
11640
|
-
(await
|
|
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 (
|
|
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(
|
|
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(
|
|
11895
|
-
repairOperations.delete(
|
|
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
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
11917
|
-
|
|
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
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
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
|
-
|
|
11941
|
-
db
|
|
11942
|
-
|
|
12560
|
+
else if (fs.existsSync(absPath)) {
|
|
12561
|
+
await indexFile(absPath, relativePath, repoPath, db);
|
|
12562
|
+
recordIndexState(db, repoPath, relativePath);
|
|
11943
12563
|
}
|
|
11944
|
-
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
11952
|
-
|
|
11953
|
-
|
|
11954
|
-
|
|
11955
|
-
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
11960
|
-
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
11967
|
-
|
|
11968
|
-
|
|
11969
|
-
|
|
11970
|
-
|
|
11971
|
-
|
|
11972
|
-
|
|
11973
|
-
|
|
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
|
-
|
|
11981
|
-
|
|
11982
|
-
|
|
11983
|
-
|
|
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
|
-
|
|
11987
|
-
|
|
11988
|
-
|
|
11989
|
-
|
|
11990
|
-
|
|
11991
|
-
|
|
11992
|
-
|
|
11993
|
-
|
|
11994
|
-
|
|
11995
|
-
|
|
11996
|
-
|
|
11997
|
-
|
|
11998
|
-
|
|
11999
|
-
|
|
12000
|
-
|
|
12001
|
-
|
|
12002
|
-
|
|
12003
|
-
|
|
12004
|
-
|
|
12005
|
-
|
|
12006
|
-
|
|
12007
|
-
|
|
12008
|
-
|
|
12009
|
-
|
|
12010
|
-
|
|
12011
|
-
|
|
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
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
12057
|
-
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
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
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
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
|
-
|
|
12084
|
-
//
|
|
12085
|
-
|
|
12086
|
-
|
|
12087
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|