knodin 0.8.2 → 0.8.3

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.
@@ -22,6 +22,7 @@ import { compareBytes } from "../compare.js";
22
22
  import { readIndexActivity } from "../index-activity.js";
23
23
  import { runReadOnlyLspQuery } from "../lsp-readonly.js";
24
24
  import { acquireRepairLease } from "../repair-lease.js";
25
+ import { analyzeResourceReachability, resourceFingerprint, } from "../resource-reachability.js";
25
26
  import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snapshot.js";
26
27
  import { KNODIN_VERSION } from "../version.js";
27
28
  import * as ann from "./ann-hnsw.js";
@@ -65,6 +66,7 @@ export const REPO_WIDE_QUERY_PATTERNS = [
65
66
  "surprising_connections",
66
67
  "suggested_questions",
67
68
  "architecture_overview",
69
+ "resource_reachability",
68
70
  "batch_outline",
69
71
  "project_overview",
70
72
  "community",
@@ -661,6 +663,7 @@ const initPromises = new Map();
661
663
  const watchers = new Map();
662
664
  const WATCH_DEBOUNCE_MS = 50;
663
665
  const watchQueues = new Map();
666
+ const repositoryEngineOwners = new Map();
664
667
  /**
665
668
  * Test-only reclamation for the single long-lived worker (isolate:false, C50).
666
669
  * The module-level `dbInstances`/`watchers`/`watchQueues` caches are keyed by repo
@@ -718,7 +721,13 @@ export async function __evictRemovedRepoState() {
718
721
  }
719
722
  // Bumped whenever any repo is (re)indexed, so cached community-detection results
720
723
  // below can be invalidated cheaply instead of recomputed on every call.
724
+ const KNODIN_SCHEMA_VERSION = 22;
725
+ const LAST_REBUILD_SCHEMA_VERSION = 22;
721
726
  let indexGeneration = 0;
727
+ const resourceReachabilityCache = new Map();
728
+ const RESOURCE_REACHABILITY_CACHE_LIMIT = 32;
729
+ const resourceCorpusCache = new Map();
730
+ const RESOURCE_CORPUS_CACHE_LIMIT = 8;
722
731
  const mapCache = new Map();
723
732
  const minimalMapCache = new Map();
724
733
  const MINIMAL_MAP_CACHE_LIMIT = 32;
@@ -3014,11 +3023,21 @@ function resolveImportPath(sourceFile, importSource, repoPath) {
3014
3023
  // repoPath -> (package name -> { dir, entryFile }) for workspace/package-alias
3015
3024
  // import resolution. Cached because it scans package.json files.
3016
3025
  const packageMapCache = new Map();
3026
+ function invalidateResolutionCaches(repoPath) {
3027
+ const resolved = path.resolve(repoPath);
3028
+ packageMapCache.delete(resolved);
3029
+ const prefix = `${resolved}\0`;
3030
+ for (const key of reExportCache.keys())
3031
+ if (key.startsWith(prefix))
3032
+ reExportCache.delete(key);
3033
+ }
3017
3034
  function getPackageMap(repoPath) {
3018
- const cached = packageMapCache.get(repoPath);
3019
- if (cached)
3020
- return cached;
3035
+ const resolvedRepoPath = path.resolve(repoPath);
3036
+ const cached = packageMapCache.get(resolvedRepoPath);
3037
+ if (cached?.generation === indexGeneration)
3038
+ return cached.value;
3021
3039
  const map = new Map();
3040
+ let cacheable = true;
3022
3041
  for (const rel of walkRepoFiles(repoPath, {
3023
3042
  accept: (file) => path.posix.basename(file) === "package.json",
3024
3043
  })) {
@@ -3032,9 +3051,14 @@ function getPackageMap(repoPath) {
3032
3051
  });
3033
3052
  }
3034
3053
  }
3035
- catch { }
3054
+ catch {
3055
+ // A file can disappear or be rewritten while discovery is running. Do
3056
+ // not turn that transient failure into a process-lifetime empty mapping.
3057
+ cacheable = false;
3058
+ }
3036
3059
  }
3037
- packageMapCache.set(repoPath, map);
3060
+ if (cacheable)
3061
+ packageMapCache.set(resolvedRepoPath, { generation: indexGeneration, value: map });
3038
3062
  return map;
3039
3063
  }
3040
3064
  /** Best source entry file for a package: prefer source-y fields, else src/index, else index. */
@@ -3083,8 +3107,8 @@ const reExportCache = new Map();
3083
3107
  function getReExports(repoPath, relFile) {
3084
3108
  const cacheKey = `${repoPath}\0${relFile}`;
3085
3109
  const cached = reExportCache.get(cacheKey);
3086
- if (cached)
3087
- return cached;
3110
+ if (cached?.generation === indexGeneration)
3111
+ return cached.value;
3088
3112
  const named = new Map();
3089
3113
  const stars = [];
3090
3114
  try {
@@ -3107,9 +3131,12 @@ function getReExports(repoPath, relFile) {
3107
3131
  stars.push(m[1]);
3108
3132
  }
3109
3133
  }
3110
- catch { }
3134
+ catch {
3135
+ // Never cache a transient read failure as an authoritative empty barrel.
3136
+ return { named, stars };
3137
+ }
3111
3138
  const result = { named, stars };
3112
- reExportCache.set(cacheKey, result);
3139
+ reExportCache.set(cacheKey, { generation: indexGeneration, value: result });
3113
3140
  return result;
3114
3141
  }
3115
3142
  /**
@@ -3341,6 +3368,31 @@ function salesforceMetadataFile(relativePath) {
3341
3368
  return { type: "approval", object: match[1], name: match[2] };
3342
3369
  return null;
3343
3370
  }
3371
+ /** Resolve only one locally declared, annotated Apex invocable method. */
3372
+ function findUniqueInvocableApexMethod(className, methodName, repoPath) {
3373
+ const file = findUniqueApexClassFilePath(className, repoPath);
3374
+ if (!file)
3375
+ return null;
3376
+ let source;
3377
+ try {
3378
+ source = fs.readFileSync(path.join(repoPath, file), "utf8");
3379
+ }
3380
+ catch {
3381
+ return null;
3382
+ }
3383
+ const matches = [];
3384
+ for (const match of source.matchAll(/@InvocableMethod\b[\s\S]{0,300}?\b(?:global|public)\s+static\s(?:\s|[^({;]*?(?<!\s))\s+([a-z]\w*)\s*\(/gi)) {
3385
+ if (!isExecutablePosition(source, match.index ?? 0) || match[1] !== methodName)
3386
+ continue;
3387
+ matches.push({
3388
+ file,
3389
+ method: methodName,
3390
+ line: source.slice(0, match.index ?? 0).split("\n").length,
3391
+ evidence: match[0].trim(),
3392
+ });
3393
+ }
3394
+ return matches.length === 1 ? matches[0] : null;
3395
+ }
3344
3396
  function staticXmlTagValues(content, tag) {
3345
3397
  const uncommented = content.replace(/<!--[\s\S]*?-->/g, "");
3346
3398
  const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
@@ -3525,8 +3577,8 @@ function extractApexPlatformDependencies(content, repoPath) {
3525
3577
  /** Index C20's static, deployable Salesforce DX metadata without interpreting formulas or runtime values. */
3526
3578
  async function indexSalesforceMetadataFile(content, relativePath, repoPath, db, metadata) {
3527
3579
  const dependencies = [];
3528
- const add = (to, kind, sourceEvidence) => {
3529
- dependencies.push({ to, kind, confidence: 1, sourceEvidence });
3580
+ const add = (to, kind, sourceEvidence, methodReference) => {
3581
+ dependencies.push({ to, kind, confidence: 1, sourceEvidence, methodReference });
3530
3582
  };
3531
3583
  const addField = (object, value, kind, evidence) => {
3532
3584
  const qualified = value.includes(".") ? value : `${object}.${value}`;
@@ -3586,9 +3638,18 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
3586
3638
  const target = /^([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(entry.value);
3587
3639
  if (!target)
3588
3640
  continue;
3589
- const classFile = findApexMethodFilePath(target[1], target[2], repoPath);
3590
- if (classFile)
3591
- add(classFile, "flow_apex_action", entry.evidence);
3641
+ const invocable = findUniqueInvocableApexMethod(target[1], target[2], repoPath);
3642
+ if (!invocable)
3643
+ continue;
3644
+ const actionOffset = action.index ?? 0;
3645
+ const entryOffset = content.indexOf(entry.evidence, actionOffset);
3646
+ const line = content.slice(0, Math.max(actionOffset, entryOffset)).split("\n").length;
3647
+ add(invocable.file, "flow_apex_action", entry.evidence, {
3648
+ callee: invocable.method,
3649
+ calleeFile: invocable.file,
3650
+ line,
3651
+ column: 0,
3652
+ });
3592
3653
  }
3593
3654
  }
3594
3655
  for (const entry of staticXmlTagValues(content, "extensionName")) {
@@ -3618,9 +3679,10 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
3618
3679
  deleteSymbolsForFile(db, relativePath);
3619
3680
  db.run("DELETE FROM dependencies WHERE fromFile = ?", [relativePath]);
3620
3681
  db.run("DELETE FROM mcp_tools WHERE filePath = ?", [relativePath]);
3682
+ const flowSymbol = metadata.type === "flow" ? metadata.name : path.basename(relativePath, ".xml");
3621
3683
  db.run(`INSERT INTO symbols (name, kind, filePath, startLine, endLine, startCol, endCol, summary)
3622
3684
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
3623
- path.basename(relativePath, ".xml"),
3685
+ flowSymbol,
3624
3686
  "salesforce-metadata",
3625
3687
  relativePath,
3626
3688
  1,
@@ -3629,6 +3691,14 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
3629
3691
  0,
3630
3692
  "Salesforce DX metadata (static declarations only; formulas and runtime targets are not resolved).",
3631
3693
  ]);
3694
+ const insertRef = db.prepare(`INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind, confidence)
3695
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
3696
+ for (const dependency of dependencies) {
3697
+ if (!dependency.methodReference)
3698
+ continue;
3699
+ insertRef.run(flowSymbol, relativePath, dependency.methodReference.callee, dependency.methodReference.calleeFile, dependency.methodReference.line, dependency.methodReference.column, dependency.kind, dependency.confidence);
3700
+ }
3701
+ insertRef.finalize();
3632
3702
  const seen = new Set();
3633
3703
  const insertDep = db.prepare("INSERT INTO dependencies (fromFile, toFile, kind, confidence, sourceEvidence) VALUES (?, ?, ?, ?, ?)");
3634
3704
  for (const dependency of dependencies) {
@@ -3646,6 +3716,47 @@ async function indexSalesforceMetadataFile(content, relativePath, repoPath, db,
3646
3716
  throw error;
3647
3717
  }
3648
3718
  }
3719
+ /**
3720
+ * Revisit only indexed Flow files that literally name an incrementally changed
3721
+ * Apex class. This keeps persisted method edges coherent when an annotation,
3722
+ * overload, or declaration changes without requiring a repository-wide scan.
3723
+ */
3724
+ async function refreshFlowsForApexFiles(db, repoPath, apexFiles) {
3725
+ const classNames = new Set(apexFiles
3726
+ .filter((file) => /^force-app\/main\/default\/classes\/[A-Za-z]\w*\.cls$/.test(file))
3727
+ .map((file) => path.basename(file, ".cls")));
3728
+ if (classNames.size === 0)
3729
+ return [];
3730
+ const flowFiles = db
3731
+ .query("SELECT DISTINCT filePath FROM symbols WHERE kind = 'salesforce-metadata' AND filePath LIKE 'force-app/main/default/flows/%.flow-meta.xml' ORDER BY filePath")
3732
+ .all();
3733
+ const refreshed = [];
3734
+ for (const { filePath } of flowFiles) {
3735
+ const absolute = path.join(repoPath, filePath);
3736
+ let content;
3737
+ try {
3738
+ content = fs.readFileSync(absolute, "utf8");
3739
+ }
3740
+ catch {
3741
+ continue;
3742
+ }
3743
+ const mentionsChangedClass = [...content.matchAll(/<actionCalls>([\s\S]*?)<\/actionCalls>/g)]
3744
+ .filter((match) => /<actionType>apex<\/actionType>/.test(match[1]))
3745
+ .some((match) => staticXmlTagValues(match[1], "actionName").some((entry) => {
3746
+ const target = /^([A-Za-z]\w*)\.([A-Za-z]\w*)$/.exec(entry.value);
3747
+ return target ? classNames.has(target[1]) : false;
3748
+ }));
3749
+ if (!mentionsChangedClass)
3750
+ continue;
3751
+ const metadata = salesforceMetadataFile(filePath);
3752
+ if (!metadata || metadata.type !== "flow")
3753
+ continue;
3754
+ await indexSalesforceMetadataFile(content, filePath, repoPath, db, metadata);
3755
+ recordIndexState(db, repoPath, filePath);
3756
+ refreshed.push(filePath);
3757
+ }
3758
+ return refreshed;
3759
+ }
3649
3760
  /** Index the source-only, statically resolvable portion of one Salesforce LWC bundle member. */
3650
3761
  async function indexLwcBundleFile(content, relativePath, repoPath, db, bundle) {
3651
3762
  const dependencies = [
@@ -5873,6 +5984,392 @@ async function indexFile(absolutePath, relativePath, repoPath, db, unparsed) {
5873
5984
  tallyOne(unparsed, extensionBucket(relativePath));
5874
5985
  }
5875
5986
  }
5987
+ /**
5988
+ * Rebuild the deliberately bounded TypeScript DI overlay from current source.
5989
+ * The overlay is derived into the existing references store, so every existing
5990
+ * graph surface sees the exact same edges and incremental indexing can replace
5991
+ * stale wiring atomically without a second schema or cache.
5992
+ */
5993
+ function reconcileTypeScriptDi(db, repoPath) {
5994
+ const files = collectRepoFiles(repoPath).filter((file) => (file.endsWith(".ts") || file.endsWith(".tsx")) && !file.endsWith(".d.ts"));
5995
+ const frameworkFiles = files.filter((file) => {
5996
+ try {
5997
+ const source = fs.readFileSync(path.join(repoPath, file), "utf8");
5998
+ const importsOnly = maskBlockComments(source, (text) => text.replace(/[^\n]/g, " ")).replace(/(^|\s)\/\/.*$/gm, (text, prefix) => prefix + " ".repeat(text.length - prefix.length));
5999
+ return /^\s*import\b[^\n]*\bfrom\s+["'](?:inversify|tsyringe)["']/m.test(importsOnly);
6000
+ }
6001
+ catch {
6002
+ return false;
6003
+ }
6004
+ });
6005
+ const frameworkFileSet = new Set(frameworkFiles);
6006
+ for (const row of db
6007
+ .query("SELECT id, filePath FROM symbols WHERE kind = 'di_module'")
6008
+ .all())
6009
+ if (!frameworkFileSet.has(row.filePath))
6010
+ db.run("DELETE FROM symbols WHERE id = ?", [row.id]);
6011
+ for (const file of frameworkFiles) {
6012
+ const present = db
6013
+ .query("SELECT 1 AS present FROM symbols WHERE kind = 'di_module' AND filePath = ? LIMIT 1")
6014
+ .get(file);
6015
+ if (!present)
6016
+ db.run("INSERT INTO symbols(name, kind, filePath, startLine, endLine, startCol, endCol, summary) VALUES (?, 'di_module', ?, 1, 1, 0, 0, ?)", [`di-module:${file}`, file, `Static DI module for ${file}`]);
6017
+ }
6018
+ if (frameworkFiles.length > 0)
6019
+ persistSymbolIdentities(db, repoPath, frameworkFiles);
6020
+ const symbols = db.query("SELECT * FROM symbols").all();
6021
+ const byFile = new Map();
6022
+ for (const symbol of symbols) {
6023
+ const rows = byFile.get(symbol.filePath) ?? [];
6024
+ rows.push(symbol);
6025
+ byFile.set(symbol.filePath, rows);
6026
+ }
6027
+ const candidates = [];
6028
+ const bindingCandidates = [];
6029
+ const resolutionCandidates = [];
6030
+ const ownerAt = (file, line) => {
6031
+ const rows = byFile.get(file) ?? [];
6032
+ return (rows
6033
+ .filter((row) => row.startLine <= line &&
6034
+ row.endLine >= line &&
6035
+ ["function", "method", "class"].includes(row.kind))
6036
+ .sort((a, b) => a.endLine - a.startLine - (b.endLine - b.startLine) || (a.kind === "class" ? 1 : -1))[0] ?? rows.find((row) => row.kind === "di_module"));
6037
+ };
6038
+ const exactSymbol = (file, name) => {
6039
+ const matches = (byFile.get(file) ?? []).filter((row) => row.name === name);
6040
+ return matches.length === 1 ? matches[0] : undefined;
6041
+ };
6042
+ for (const file of files) {
6043
+ let source;
6044
+ try {
6045
+ source = fs.readFileSync(path.join(repoPath, file), "utf8");
6046
+ }
6047
+ catch {
6048
+ continue;
6049
+ }
6050
+ const masked = maskBlockComments(source, (text) => text.replace(/[^\n]/g, " ")).replace(/(^|\s)\/\/.*$/gm, (text, prefix) => prefix + " ".repeat(text.length - prefix.length));
6051
+ const codePosition = new Uint8Array(masked.length).fill(1);
6052
+ let quote = "";
6053
+ for (let index = 0; index < masked.length; index++) {
6054
+ const char = masked[index];
6055
+ if (quote) {
6056
+ codePosition[index] = 0;
6057
+ if (char === "\\") {
6058
+ if (index + 1 < masked.length)
6059
+ codePosition[++index] = 0;
6060
+ }
6061
+ else if (char === quote)
6062
+ quote = "";
6063
+ }
6064
+ else if (char === '"' || char === "'" || char === "`") {
6065
+ quote = char;
6066
+ codePosition[index] = 0;
6067
+ }
6068
+ }
6069
+ const isCodeMatch = (match) => codePosition[match.index ?? 0] === 1;
6070
+ const hasInversify = [...masked.matchAll(/\bfrom\s+["']inversify["']/g)].some(isCodeMatch);
6071
+ const hasTsyringe = [...masked.matchAll(/\bfrom\s+["']tsyringe["']/g)].some(isCodeMatch);
6072
+ if (!hasInversify && !hasTsyringe)
6073
+ continue;
6074
+ const imports = new Map();
6075
+ for (const match of masked.matchAll(/\bimport\s+(?:type\s+)?\{([^}]+)\}\s*from\s*["']([^"']+)["']/g)) {
6076
+ if (!isCodeMatch(match))
6077
+ continue;
6078
+ for (const specifier of match[1].split(",")) {
6079
+ const parts = specifier
6080
+ .trim()
6081
+ .replace(/^type\s+/, "")
6082
+ .split(/\s+as\s+/);
6083
+ if (parts[0])
6084
+ imports.set(parts[1] ?? parts[0], { original: parts[0], module: match[2] });
6085
+ }
6086
+ }
6087
+ for (const match of masked.matchAll(/\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*["']([^"']+)["']/g))
6088
+ if (isCodeMatch(match))
6089
+ imports.set(match[1], { original: match[1], module: match[2] });
6090
+ const receivers = new Set();
6091
+ for (const [local, binding] of imports)
6092
+ if (binding.module === "tsyringe" && binding.original === "container")
6093
+ receivers.add(local);
6094
+ if (hasInversify) {
6095
+ const containerTypes = [...imports]
6096
+ .filter(([, binding]) => binding.module === "inversify" && binding.original === "Container")
6097
+ .map(([local]) => local);
6098
+ for (const typeName of containerTypes) {
6099
+ const escapedType = typeName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6100
+ for (const match of masked.matchAll(new RegExp(String.raw `\b(?:const|let|var)\s+(\w+)\s*=\s*new\s+${escapedType}\b`, "g")))
6101
+ if (isCodeMatch(match))
6102
+ receivers.add(match[1]);
6103
+ for (const match of masked.matchAll(new RegExp(String.raw `\b([A-Za-z_$][\w$]*)\s*:\s*${escapedType}\b`, "g")))
6104
+ if (isCodeMatch(match))
6105
+ receivers.add(match[1]);
6106
+ }
6107
+ }
6108
+ const point = (index) => getLineAndColumnForIndex(source, index);
6109
+ const conditionalAt = (index) => {
6110
+ const stack = [];
6111
+ for (let cursor = 0; cursor < index; cursor++) {
6112
+ if (masked[cursor] === "{") {
6113
+ const prefix = masked.slice(Math.max(0, cursor - 240), cursor);
6114
+ stack.push(/\b(?:if|switch|for|while)\s*\([^{}]*\)\s*$/.test(prefix));
6115
+ }
6116
+ else if (masked[cursor] === "}")
6117
+ stack.pop();
6118
+ }
6119
+ if (stack.includes(true))
6120
+ return true;
6121
+ const boundary = Math.max(masked.lastIndexOf(";", index - 1), masked.lastIndexOf("{", index - 1), masked.lastIndexOf("}", index - 1));
6122
+ const prefix = masked.slice(boundary + 1, index);
6123
+ return /\b(?:if|switch|for|while)\s*\(|&&|\|\||\?|=>/.test(prefix);
6124
+ };
6125
+ const canonicalToken = (expression) => {
6126
+ const token = expression.trim();
6127
+ const literal = /^(?:"([^"\n]*)"|'([^'\n]*)')$/.exec(token);
6128
+ if (literal)
6129
+ return { value: `literal:${literal[1] ?? literal[2]}` };
6130
+ if (!/^[A-Za-z_$][\w$]*$/.test(token))
6131
+ return { reason: "Token is dynamic or computed." };
6132
+ const imported = imports.get(token);
6133
+ if (imported) {
6134
+ const moduleFile = resolveModuleToFile(file, imported.module, repoPath);
6135
+ if (!moduleFile)
6136
+ return { reason: `Token import ${token} does not resolve uniquely.` };
6137
+ const definitionFile = followReExports(repoPath, moduleFile, imported.original, 0);
6138
+ if (!exactSymbol(definitionFile, imported.original))
6139
+ return { reason: `Token ${token} does not have one indexed declaration.` };
6140
+ return { value: `symbol:${definitionFile}#${imported.original}` };
6141
+ }
6142
+ if (!exactSymbol(file, token))
6143
+ return { reason: `Token ${token} does not have one indexed declaration.` };
6144
+ return { value: `symbol:${file}#${token}` };
6145
+ };
6146
+ const implementation = (name) => {
6147
+ if (!/^[A-Za-z_$][\w$]*$/.test(name))
6148
+ return undefined;
6149
+ const imported = imports.get(name);
6150
+ if (imported) {
6151
+ const moduleFile = resolveModuleToFile(file, imported.module, repoPath);
6152
+ if (!moduleFile)
6153
+ return undefined;
6154
+ return exactSymbol(followReExports(repoPath, moduleFile, imported.original, 0), imported.original);
6155
+ }
6156
+ return exactSymbol(file, name);
6157
+ };
6158
+ const addOmission = (index, kind, reason) => {
6159
+ if (codePosition[index] !== 1)
6160
+ return;
6161
+ const where = point(index);
6162
+ candidates.push({
6163
+ type: "omission",
6164
+ owner: ownerAt(file, where.line),
6165
+ file,
6166
+ line: where.line,
6167
+ column: where.column,
6168
+ kind,
6169
+ reason,
6170
+ evidence: source.split(/\r?\n/)[where.line - 1]?.trim().slice(0, 240) ?? "",
6171
+ });
6172
+ };
6173
+ const registryFor = (framework, receiver, index) => {
6174
+ if (framework === "tsyringe")
6175
+ return "tsyringe:global";
6176
+ const before = masked.slice(0, index);
6177
+ if (new RegExp(String.raw `\b(?:const|let|var)\s+${receiver}\s*=\s*new\s+`).test(before))
6178
+ return `inversify:${file}:module:${receiver}`;
6179
+ const where = point(index);
6180
+ const owner = ownerAt(file, where.line);
6181
+ return `inversify:${file}:${owner?.identity ?? owner?.name ?? "module"}:${receiver}`;
6182
+ };
6183
+ const addBinding = (index, tokenExpression, implementationName, framework, receiver) => {
6184
+ if (codePosition[index] !== 1)
6185
+ return;
6186
+ if (conditionalAt(index))
6187
+ return addOmission(index, "di_conditional_registration", "Conditional registration is runtime-dependent.");
6188
+ const where = point(index);
6189
+ const token = canonicalToken(tokenExpression);
6190
+ if (!token.value)
6191
+ return addOmission(index, "di_dynamic_token", token.reason ?? "Dynamic token.");
6192
+ const target = implementation(implementationName.trim());
6193
+ if (!target)
6194
+ return addOmission(index, "di_unresolved_implementation", `Implementation ${implementationName.trim()} does not resolve to one indexed symbol.`);
6195
+ bindingCandidates.push({
6196
+ type: "binding",
6197
+ token: token.value,
6198
+ framework,
6199
+ registry: registryFor(framework, receiver, index),
6200
+ implementation: target,
6201
+ owner: ownerAt(file, where.line),
6202
+ file,
6203
+ line: where.line,
6204
+ column: where.column,
6205
+ evidence: source.split(/\r?\n/)[where.line - 1]?.trim().slice(0, 240) ?? "",
6206
+ });
6207
+ };
6208
+ const addResolution = (index, tokenExpression, framework, receiver) => {
6209
+ if (codePosition[index] !== 1)
6210
+ return;
6211
+ if (conditionalAt(index))
6212
+ return addOmission(index, "di_conditional_resolution", "Conditional resolution is runtime-dependent.");
6213
+ const where = point(index);
6214
+ const token = canonicalToken(tokenExpression);
6215
+ if (!token.value)
6216
+ return addOmission(index, "di_dynamic_token", token.reason ?? "Dynamic token.");
6217
+ resolutionCandidates.push({
6218
+ type: "resolution",
6219
+ token: token.value,
6220
+ framework,
6221
+ registry: receiver ? registryFor(framework, receiver, index) : `${framework}:decorator`,
6222
+ owner: ownerAt(file, where.line),
6223
+ file,
6224
+ line: where.line,
6225
+ column: where.column,
6226
+ evidence: source.split(/\r?\n/)[where.line - 1]?.trim().slice(0, 240) ?? "",
6227
+ });
6228
+ };
6229
+ if (hasInversify) {
6230
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.bind\s*\(([^()]+)\)\.to\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g))
6231
+ if (receivers.has(match[1]))
6232
+ addBinding(match.index ?? 0, match[2], match[3], "inversify", match[1]);
6233
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.bind\s*\(\s*([A-Za-z_$][\w$]*)\s*\)\.toSelf\s*\(\s*\)/g))
6234
+ if (receivers.has(match[1]))
6235
+ addBinding(match.index ?? 0, match[2], match[2], "inversify", match[1]);
6236
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.get\s*\(([^()]+)\)/g))
6237
+ if (receivers.has(match[1]))
6238
+ addResolution(match.index ?? 0, match[2], "inversify", match[1]);
6239
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.bind\s*\([^)]*\)\.toFactory\s*\(/g))
6240
+ if (receivers.has(match[1]))
6241
+ addOmission(match.index ?? 0, "di_unsupported_factory", "Factory providers are runtime behavior and are not resolved.");
6242
+ }
6243
+ if (hasTsyringe) {
6244
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.register\s*\(([^,()]+),\s*\{\s*useClass\s*:\s*([A-Za-z_$][\w$]*)\s*\}\s*\)/g))
6245
+ if (receivers.has(match[1]))
6246
+ addBinding(match.index ?? 0, match[2], match[3], "tsyringe", match[1]);
6247
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.registerSingleton\s*\(([^,()]+),\s*([A-Za-z_$][\w$]*)\s*\)/g))
6248
+ if (receivers.has(match[1]))
6249
+ addBinding(match.index ?? 0, match[2], match[3], "tsyringe", match[1]);
6250
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.registerSingleton\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g))
6251
+ if (receivers.has(match[1]))
6252
+ addBinding(match.index ?? 0, match[2], match[2], "tsyringe", match[1]);
6253
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.resolve\s*\(([^()]+)\)/g))
6254
+ if (receivers.has(match[1]))
6255
+ addResolution(match.index ?? 0, match[2], "tsyringe", match[1]);
6256
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.register\s*\(([^,()]+),\s*\{\s*useFactory\s*:/g))
6257
+ if (receivers.has(match[1]))
6258
+ addOmission(match.index ?? 0, "di_unsupported_factory", "Factory providers are runtime behavior and are not resolved.");
6259
+ }
6260
+ for (const [local, binding] of imports) {
6261
+ if (binding.original !== "inject" || !["inversify", "tsyringe"].includes(binding.module))
6262
+ continue;
6263
+ const escaped = local.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6264
+ for (const match of masked.matchAll(new RegExp(String.raw `@${escaped}\s*\(([^()]+)\)`, "g")))
6265
+ addResolution(match.index ?? 0, match[1], binding.module);
6266
+ }
6267
+ for (const match of masked.matchAll(/\b([A-Za-z_$][\w$]*)\.(bind|get|resolve|register|registerSingleton)\s*\(/g)) {
6268
+ if (!receivers.has(match[1]))
6269
+ addOmission(match.index ?? 0, "di_unverified_receiver", `Receiver ${match[1]} is not a source-proven framework container.`);
6270
+ }
6271
+ for (const receiver of receivers) {
6272
+ const escaped = receiver.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6273
+ for (const match of masked.matchAll(new RegExp(String.raw `\b${escaped}\.load\s*\(`, "g")))
6274
+ addOmission(match.index ?? 0, "di_runtime_module", "Runtime container modules are not statically expanded.");
6275
+ }
6276
+ }
6277
+ const bindingsByToken = new Map();
6278
+ for (const binding of bindingCandidates) {
6279
+ const key = `${binding.framework}\0${binding.registry}\0${binding.token}`;
6280
+ const rows = bindingsByToken.get(key) ?? [];
6281
+ rows.push(binding);
6282
+ bindingsByToken.set(key, rows);
6283
+ }
6284
+ for (const [key, bindings] of bindingsByToken) {
6285
+ if (bindings.length === 1)
6286
+ candidates.push(bindings[0]);
6287
+ else {
6288
+ for (const binding of bindings) {
6289
+ candidates.push({
6290
+ ...binding,
6291
+ type: "omission",
6292
+ kind: "di_ambiguous_binding",
6293
+ reason: `Registry token ${key.split("\0").at(-1)} has ${bindings.length} static bindings.`,
6294
+ });
6295
+ }
6296
+ }
6297
+ }
6298
+ for (const resolution of resolutionCandidates) {
6299
+ const bindings = resolution.registry?.endsWith(":decorator")
6300
+ ? bindingCandidates.filter((binding) => binding.framework === resolution.framework && binding.token === resolution.token)
6301
+ : (bindingsByToken.get(`${resolution.framework}\0${resolution.registry}\0${resolution.token}`) ?? []);
6302
+ if (bindings.length === 1)
6303
+ candidates.push({ ...resolution, implementation: bindings[0].implementation });
6304
+ else
6305
+ candidates.push({
6306
+ ...resolution,
6307
+ type: "omission",
6308
+ kind: bindings.length > 1 ? "di_ambiguous_resolution" : "di_unbound_resolution",
6309
+ reason: bindings.length > 1
6310
+ ? `Token ${resolution.token} has multiple bindings.`
6311
+ : `Token ${resolution.token} has no static binding.`,
6312
+ });
6313
+ }
6314
+ db.run("BEGIN TRANSACTION;");
6315
+ try {
6316
+ db.run("DELETE FROM \"references\" WHERE kind IN ('di_binding', 'di_resolution', 'di_omission')");
6317
+ const insert = db.prepare(`
6318
+ INSERT INTO "references" (callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind, confidence)
6319
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
6320
+ `);
6321
+ for (const candidate of candidates) {
6322
+ if (!candidate.owner)
6323
+ continue;
6324
+ if (candidate.type === "omission") {
6325
+ insert.run(candidate.owner.name, candidate.file, `${candidate.kind}:${candidate.reason}`, null, candidate.line, candidate.column, "di_omission", 0);
6326
+ }
6327
+ else if (candidate.implementation) {
6328
+ insert.run(candidate.owner.name, candidate.file, candidate.implementation.name, candidate.implementation.filePath, candidate.line, candidate.column, candidate.type === "binding" ? "di_binding" : "di_resolution", 1);
6329
+ }
6330
+ }
6331
+ insert.finalize();
6332
+ db.run("COMMIT;");
6333
+ }
6334
+ catch (error) {
6335
+ db.run("ROLLBACK;");
6336
+ throw error;
6337
+ }
6338
+ persistReferenceEndpoints(db);
6339
+ setMeta(db, "typescriptDiVersion", "1");
6340
+ }
6341
+ /**
6342
+ * Decide whether a changed-file repair can affect the repository-wide DI
6343
+ * overlay without reading source outside that repair set. This check must run
6344
+ * before file-owned rows are replaced so removal of a framework import still
6345
+ * triggers reconciliation through the previously persisted DI evidence.
6346
+ */
6347
+ function repairPathsAffectTypeScriptDi(db, repoPath, repairPaths) {
6348
+ const typeScriptPaths = repairPaths.filter((file) => (file.endsWith(".ts") || file.endsWith(".tsx")) && !file.endsWith(".d.ts"));
6349
+ if (typeScriptPaths.length === 0)
6350
+ return false;
6351
+ const persistedEvidence = db.query(`SELECT 1 AS present
6352
+ FROM symbols
6353
+ WHERE kind = 'di_module' AND filePath = ?
6354
+ UNION ALL
6355
+ SELECT 1 AS present
6356
+ FROM "references"
6357
+ WHERE kind IN ('di_binding', 'di_resolution', 'di_omission')
6358
+ AND (callerFile = ? OR calleeFile = ?)
6359
+ LIMIT 1`);
6360
+ if (typeScriptPaths.some((file) => persistedEvidence.get(file, file, file)))
6361
+ return true;
6362
+ return typeScriptPaths.some((file) => {
6363
+ try {
6364
+ const source = fs.readFileSync(path.join(repoPath, file), "utf8");
6365
+ const importsOnly = maskBlockComments(source, (text) => text.replace(/[^\n]/g, " ")).replace(/(^|\s)\/\/.*$/gm, (text, prefix) => prefix + " ".repeat(text.length - prefix.length));
6366
+ return /^\s*import\b[^\n]*\bfrom\s+["'](?:inversify|tsyringe)["']/m.test(importsOnly);
6367
+ }
6368
+ catch {
6369
+ return false;
6370
+ }
6371
+ });
6372
+ }
5876
6373
  // The installed Transformers runtime was slower for every tested multi-item
5877
6374
  // batch on the R43 clean-build protocol (R44). Keep the safe no-regression
5878
6375
  // default while retaining the bounded override for future runtime/hardware
@@ -6584,6 +7081,7 @@ async function reconcileIndex(repoPath, db, progress) {
6584
7081
  if (reindexed > 0) {
6585
7082
  progress?.("finalizing", 0, "Refreshing identities and semantic embeddings");
6586
7083
  persistSymbolIdentities(db, repoPath, reindexedPaths);
7084
+ reconcileTypeScriptDi(db, repoPath);
6587
7085
  await indexEmbeddings(db, repoPath, progress);
6588
7086
  indexGeneration++;
6589
7087
  }
@@ -6997,6 +7495,7 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = false) {
6997
7495
  }
6998
7496
  progress?.("finalizing", 0, "Persisting symbol identities and semantic index");
6999
7497
  persistSymbolIdentities(db, repoPath);
7498
+ reconcileTypeScriptDi(db, repoPath);
7000
7499
  // Structure is complete at this point; embeddings are the expensive tail. When
7001
7500
  // deferred, `semanticReadiness` reports the gap until a later pass fills it —
7002
7501
  // `indexEmbeddings` only ever embeds symbols that lack one, so resuming is
@@ -7080,6 +7579,7 @@ function startFileWatcher(repoPath, db) {
7080
7579
  }
7081
7580
  }
7082
7581
  persistSymbolIdentities(db, resolvedRepoPath, paths);
7582
+ reconcileTypeScriptDi(db, resolvedRepoPath);
7083
7583
  await indexEmbeddings(db, resolvedRepoPath);
7084
7584
  queue.embeddingReconciliations++;
7085
7585
  indexGeneration++;
@@ -7220,11 +7720,9 @@ async function getOrInitDb(repoPath, options = {}) {
7220
7720
  // newly indexable JSON files. v22 admits markdown as a link-only
7221
7721
  // participant, so previously skipped documentation must be visited once
7222
7722
  // to populate its doc_link / doc_link_broken edges.
7223
- const KNODIN_SCHEMA_VERSION = 22;
7224
7723
  // Highest version whose upgrade needs the stored data REBUILT. Versions
7225
7724
  // above it migrate in place, so an upgrade costs a DELETE rather than a
7226
7725
  // full re-index + re-embed (~40 min of CPU on an 18k-symbol corpus).
7227
- const LAST_REBUILD_SCHEMA_VERSION = 22;
7228
7726
  const versionRow = db.query("PRAGMA user_version").get();
7229
7727
  const storedVersion = versionRow?.user_version ?? 0;
7230
7728
  const needsMcpBackfill = storedVersion < 17;
@@ -7479,6 +7977,8 @@ async function getOrInitDb(repoPath, options = {}) {
7479
7977
  options.onProgress?.("finalizing", 0, `Persisting stable identities for ${missingIdentities.toLocaleString()} missing symbols`);
7480
7978
  persistSymbolIdentities(db, normalizedPath);
7481
7979
  }
7980
+ if (getMeta(db, "typescriptDiVersion") !== "1")
7981
+ reconcileTypeScriptDi(db, normalizedPath);
7482
7982
  if (needsMcpBackfill || getMeta(db, "mcpBackfillVersion") !== "17") {
7483
7983
  let backfillComplete = true;
7484
7984
  const indexedFiles = db
@@ -8082,7 +8582,8 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8082
8582
  SELECT *
8083
8583
  FROM "references"
8084
8584
  WHERE calleeSymbol = ? AND calleeFile = ?
8085
- AND (? IS NULL OR calleeIdentity = ?) AND kind = 'call'
8585
+ AND (? IS NULL OR calleeIdentity = ?)
8586
+ AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
8086
8587
  `);
8087
8588
  rows = stmt.all(targetSymbol, targetFile, targetIdentity, targetIdentity);
8088
8589
  stmt.finalize();
@@ -8091,7 +8592,7 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8091
8592
  const stmt = db.query(`
8092
8593
  SELECT id, callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind
8093
8594
  FROM "references"
8094
- WHERE calleeSymbol = ? AND kind = 'call'
8595
+ WHERE calleeSymbol = ? AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
8095
8596
  `);
8096
8597
  rows = stmt.all(targetSymbol);
8097
8598
  stmt.finalize();
@@ -8108,6 +8609,9 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8108
8609
  lineNumber: row.line,
8109
8610
  repoPath: currentRepoPath,
8110
8611
  kind: row.kind,
8612
+ confidence: row.confidence ?? 1,
8613
+ evidenceFile: row.callerFile,
8614
+ evidenceLine: row.line,
8111
8615
  });
8112
8616
  const parentCallers = findCallersFederated(allRepos, currentRepoPath, row.callerSymbol, depth - 1, visited, row.callerFile, row.callerIdentity ?? null);
8113
8617
  results.push(...parentCallers);
@@ -8143,7 +8647,7 @@ function findCallersFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8143
8647
  // Deduplicate by symbol, file, line, and repoPath
8144
8648
  const seen = new Set();
8145
8649
  return results.filter((r) => {
8146
- const key = `${r.symbol}:${r.filePath}:${r.lineNumber}:${r.repoPath}`;
8650
+ const key = `${r.symbol}:${r.filePath}:${r.lineNumber}:${r.repoPath}:${r.kind ?? "call"}`;
8147
8651
  if (seen.has(key))
8148
8652
  return false;
8149
8653
  seen.add(key);
@@ -8181,7 +8685,8 @@ function findCalleesFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8181
8685
  SELECT *
8182
8686
  FROM "references"
8183
8687
  WHERE callerSymbol = ? AND callerFile = ?
8184
- AND (? IS NULL OR callerIdentity = ?) AND kind = 'call'
8688
+ AND (? IS NULL OR callerIdentity = ?)
8689
+ AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
8185
8690
  `);
8186
8691
  rows = stmt.all(targetSymbol, sourceFile, exactSourceIdentity, exactSourceIdentity);
8187
8692
  stmt.finalize();
@@ -8190,7 +8695,7 @@ function findCalleesFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8190
8695
  const stmt = db.query(`
8191
8696
  SELECT id, callerSymbol, callerFile, calleeSymbol, calleeFile, line, column, kind
8192
8697
  FROM "references"
8193
- WHERE callerSymbol = ? AND kind = 'call'
8698
+ WHERE callerSymbol = ? AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')
8194
8699
  `);
8195
8700
  rows = stmt.all(targetSymbol);
8196
8701
  stmt.finalize();
@@ -8218,6 +8723,9 @@ function findCalleesFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8218
8723
  lineNumber: def.startLine,
8219
8724
  repoPath: currentRepoPath,
8220
8725
  kind: row.kind,
8726
+ confidence: row.confidence ?? 1,
8727
+ evidenceFile: row.callerFile,
8728
+ evidenceLine: row.line,
8221
8729
  });
8222
8730
  const childCallees = findCalleesFederated(allRepos, currentRepoPath, row.calleeSymbol, depth - 1, visited, def.filePath, def.identity ?? null);
8223
8731
  results.push(...childCallees);
@@ -8260,7 +8768,7 @@ function findCalleesFederated(allRepos, currentRepoPath, targetSymbol, depth, vi
8260
8768
  // Deduplicate
8261
8769
  const seen = new Set();
8262
8770
  return results.filter((r) => {
8263
- const key = `${r.symbol}:${r.filePath}:${r.lineNumber}:${r.repoPath}`;
8771
+ const key = `${r.symbol}:${r.filePath}:${r.lineNumber}:${r.repoPath}:${r.kind ?? "call"}`;
8264
8772
  if (seen.has(key))
8265
8773
  return false;
8266
8774
  seen.add(key);
@@ -9714,24 +10222,28 @@ function findImportSpecifierLines(absPath, symbol) {
9714
10222
  * threaded through each operation's many return sites, so the tag cannot be
9715
10223
  * forgotten on an early return (a not-found explain, a refused rename).
9716
10224
  */
9717
- function withStaleness(engine) {
10225
+ function withStaleness(engine, claimRepository) {
9718
10226
  return {
9719
10227
  async explain(symbol, repoPath, detailLevel, selector) {
10228
+ claimRepository(repoPath);
9720
10229
  const result = await engine.explain(symbol, repoPath, detailLevel, selector);
9721
10230
  result.staleness = stalenessFor(repoPath);
9722
10231
  return result;
9723
10232
  },
9724
10233
  async review(base, repoPath, detailLevel, options) {
10234
+ claimRepository(repoPath);
9725
10235
  const result = await engine.review(base, repoPath, detailLevel, options);
9726
10236
  result.staleness = stalenessFor(repoPath);
9727
10237
  return result;
9728
10238
  },
9729
10239
  async dependencyGraph(repoPath) {
10240
+ claimRepository(repoPath);
9730
10241
  const result = await engine.dependencyGraph(repoPath);
9731
10242
  result.staleness = stalenessFor(repoPath);
9732
10243
  return result;
9733
10244
  },
9734
10245
  async map(repoPath, detailLevel = "minimal", options) {
10246
+ claimRepository(repoPath);
9735
10247
  const raw = await engine.map(repoPath, detailLevel, options);
9736
10248
  const result = options
9737
10249
  ? {
@@ -9775,21 +10287,26 @@ function withStaleness(engine) {
9775
10287
  return result;
9776
10288
  },
9777
10289
  async wiki(repoPath, force) {
10290
+ claimRepository(repoPath);
9778
10291
  const result = await engine.wiki(repoPath, force);
9779
10292
  result.staleness = stalenessFor(repoPath);
9780
10293
  return result;
9781
10294
  },
9782
10295
  status(repoPath, options) {
10296
+ claimRepository(repoPath);
9783
10297
  return engine.status(repoPath, options);
9784
10298
  },
9785
10299
  repair(repoPath, options) {
10300
+ claimRepository(repoPath);
9786
10301
  return engine.repair(repoPath, options);
9787
10302
  },
9788
10303
  index(repoPath, files, clean, options) {
10304
+ claimRepository(repoPath);
9789
10305
  // `index` IS the refresh — it has no answer to qualify.
9790
10306
  return engine.index(repoPath, files, clean, options);
9791
10307
  },
9792
10308
  async search(query, repoPath, limit, options) {
10309
+ claimRepository(repoPath);
9793
10310
  const page = await engine.search(query, repoPath, limit, options);
9794
10311
  const staleness = stalenessFor(repoPath);
9795
10312
  for (const row of page.results)
@@ -9797,11 +10314,13 @@ function withStaleness(engine) {
9797
10314
  return page;
9798
10315
  },
9799
10316
  async query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options) {
10317
+ claimRepository(repoPath);
9800
10318
  const result = await engine.query(pattern, target, repoPath, to, limit, depth, detailLevel, selector, impactOptions, options);
9801
10319
  result.staleness = stalenessFor(repoPath);
9802
10320
  return result;
9803
10321
  },
9804
10322
  async rename(oldName, newName, repoPath, apply, verify, selector) {
10323
+ claimRepository(repoPath);
9805
10324
  const result = await engine.rename(oldName, newName, repoPath, apply, verify, selector);
9806
10325
  result.staleness = stalenessFor(repoPath);
9807
10326
  return result;
@@ -9813,6 +10332,20 @@ function withStaleness(engine) {
9813
10332
  }
9814
10333
  /** Default SQLite + Tree-Sitter engine. */
9815
10334
  export function createEngine() {
10335
+ const owner = Symbol("knodin-engine");
10336
+ const ownedRepositories = new Set();
10337
+ let closed = false;
10338
+ const claimRepository = (repoPath) => {
10339
+ if (closed)
10340
+ throw new Error("knodin engine is closed");
10341
+ const resolved = path.resolve(repoPath);
10342
+ if (ownedRepositories.has(resolved))
10343
+ return;
10344
+ ownedRepositories.add(resolved);
10345
+ const owners = repositoryEngineOwners.get(resolved) ?? new Set();
10346
+ owners.add(owner);
10347
+ repositoryEngineOwners.set(resolved, owners);
10348
+ };
9816
10349
  // Bound to a name (rather than returned as a bare object literal) so the
9817
10350
  // `wiki` method below can call `engine.map(..., "standard")` and reuse its output
9818
10351
  // instead of duplicating community-detection logic. Safe: `wiki`'s body
@@ -9900,7 +10433,7 @@ export function createEngine() {
9900
10433
  const out = [];
9901
10434
  for (const c of refs) {
9902
10435
  const filePath = formatPath(c.repoPath || targetRepoPath, c.filePath);
9903
- const key = `${c.symbol}|${filePath}`;
10436
+ const key = `${c.symbol}|${filePath}|${c.kind ?? "call"}`;
9904
10437
  if (seen.has(key))
9905
10438
  continue;
9906
10439
  seen.add(key);
@@ -9912,6 +10445,16 @@ export function createEngine() {
9912
10445
  symbol: c.symbol,
9913
10446
  filePath,
9914
10447
  lineNumber: c.lineNumber,
10448
+ kind: c.kind,
10449
+ confidence: c.confidence,
10450
+ ...(c.kind?.startsWith("di_")
10451
+ ? {
10452
+ sourceEvidence: fs
10453
+ .readFileSync(path.join(c.repoPath || targetRepoPath, c.evidenceFile ?? c.filePath), "utf8")
10454
+ .split(/\r?\n/)[(c.evidenceLine ?? c.lineNumber) - 1]?.trim()
10455
+ .slice(0, 240) ?? "",
10456
+ }
10457
+ : {}),
9915
10458
  ...(definition
9916
10459
  ? {
9917
10460
  identity: symbolIdentity(definitionRepo?.path || targetRepoPath, definition),
@@ -9923,11 +10466,39 @@ export function createEngine() {
9923
10466
  };
9924
10467
  const allCallers = dedupeRefs(directCallers);
9925
10468
  const allCallees = dedupeRefs(directCallees);
10469
+ const targetDb = allRepos.find((repo) => repo.path === targetRepoPath)?.db;
10470
+ const omissions = targetDb
10471
+ ? targetDb
10472
+ .query(" SELECT calleeSymbol, callerFile, line FROM \"references\" WHERE callerSymbol = ? AND callerFile = ? AND kind = 'di_omission' ORDER BY line, calleeSymbol")
10473
+ .all(primaryDef.name, primaryDef.filePath)
10474
+ .map((row) => {
10475
+ const separator = row.calleeSymbol.indexOf(":");
10476
+ let evidence = "";
10477
+ try {
10478
+ evidence =
10479
+ fs
10480
+ .readFileSync(path.join(targetRepoPath, row.callerFile), "utf8")
10481
+ .split(/\r?\n/)[row.line - 1]?.trim()
10482
+ .slice(0, 240) ?? "";
10483
+ }
10484
+ catch { }
10485
+ return {
10486
+ kind: separator < 0 ? "di_unresolved" : row.calleeSymbol.slice(0, separator),
10487
+ reason: separator < 0 ? row.calleeSymbol : row.calleeSymbol.slice(separator + 1),
10488
+ filePath: formatPath(targetRepoPath, row.callerFile),
10489
+ lineNumber: row.line,
10490
+ evidence,
10491
+ };
10492
+ })
10493
+ : [];
9926
10494
  const allBlastFiles = Array.from(new Set(allCallers.map((c) => c.filePath)));
9927
10495
  const untested = !allCallers.some((c) => isTestFilePath(c.filePath));
9928
10496
  const minimal = detailLevel === "minimal";
9929
10497
  const cap = minimal ? 25 : 200;
9930
- const truncated = allCallers.length > cap || allCallees.length > cap || allBlastFiles.length > cap;
10498
+ const truncated = allCallers.length > cap ||
10499
+ allCallees.length > cap ||
10500
+ allBlastFiles.length > cap ||
10501
+ omissions.length > cap;
9931
10502
  return {
9932
10503
  identity: symbolIdentity(targetRepoPath, primaryDef),
9933
10504
  symbol,
@@ -9937,6 +10508,9 @@ export function createEngine() {
9937
10508
  callerCount: allCallers.length,
9938
10509
  callees: allCallees.slice(0, cap),
9939
10510
  calleeCount: allCallees.length,
10511
+ ...(omissions.length > 0
10512
+ ? { omissions: omissions.slice(0, cap), omissionCount: omissions.length }
10513
+ : {}),
9940
10514
  blastRadius: allBlastFiles.slice(0, cap),
9941
10515
  blastRadiusCount: allBlastFiles.length,
9942
10516
  untested,
@@ -10350,7 +10924,7 @@ export function createEngine() {
10350
10924
  addExtractedEdge(df.fromFile, df.toFile, df.kind, df.confidence, df.sourceEvidence);
10351
10925
  }
10352
10926
  const refsAll = db
10353
- .query('SELECT callerFile, calleeSymbol, calleeFile FROM "references"')
10927
+ .query('SELECT callerFile, calleeSymbol, calleeFile, kind, line, confidence FROM "references"')
10354
10928
  .all();
10355
10929
  for (const r of refsAll) {
10356
10930
  // Prefer the resolved calleeFile (one exact edge). When unresolved,
@@ -10364,7 +10938,12 @@ export function createEngine() {
10364
10938
  }
10365
10939
  if (!calleeFile || calleeFile === r.callerFile)
10366
10940
  continue;
10367
- addExtractedEdge(r.callerFile, calleeFile, "calls");
10941
+ addExtractedEdge(r.callerFile, calleeFile, r.kind.startsWith("di_") ? r.kind : "calls", r.confidence, r.kind.startsWith("di_")
10942
+ ? fs
10943
+ .readFileSync(path.join(resolvedRepoPath, r.callerFile), "utf8")
10944
+ .split(/\r?\n/)[r.line - 1]?.trim()
10945
+ .slice(0, 240)
10946
+ : undefined);
10368
10947
  }
10369
10948
  return {
10370
10949
  repoPath: resolvedRepoPath,
@@ -10458,7 +11037,7 @@ export function createEngine() {
10458
11037
  // Populating callers references & cross-repo routing
10459
11038
  for (const repo of allRepos) {
10460
11039
  const refsAll = repo.db
10461
- .query('SELECT callerFile, calleeSymbol, calleeFile FROM "references"')
11040
+ .query('SELECT callerFile, calleeSymbol, calleeFile, kind, line, confidence FROM "references"')
10462
11041
  .all();
10463
11042
  for (const r of refsAll) {
10464
11043
  const callerPrefixed = formatPath(repo.path, r.callerFile);
@@ -10476,7 +11055,12 @@ export function createEngine() {
10476
11055
  calleePrefixed = sameRepo[0].file;
10477
11056
  }
10478
11057
  if (calleePrefixed && calleePrefixed !== callerPrefixed) {
10479
- addExtractedEdge(callerPrefixed, calleePrefixed, "calls");
11058
+ addExtractedEdge(callerPrefixed, calleePrefixed, r.kind.startsWith("di_") ? r.kind : "calls", r.confidence, r.kind.startsWith("di_")
11059
+ ? fs
11060
+ .readFileSync(path.join(repo.path, r.callerFile), "utf8")
11061
+ .split(/\r?\n/)[r.line - 1]?.trim()
11062
+ .slice(0, 240)
11063
+ : undefined);
10480
11064
  }
10481
11065
  // 2. Cross-repo Routing: If it does not exist locally but matches endpoints in other repos
10482
11066
  const localExists = calleeFiles?.some((c) => c.repoPath === repo.path);
@@ -10757,6 +11341,12 @@ export function createEngine() {
10757
11341
  schemaProblems.push(`required index_state column is missing: ${column}`);
10758
11342
  if (schemaVersion < 18)
10759
11343
  schemaProblems.push(`schema ${schemaVersion} is older than required schema 18`);
11344
+ if (schemaVersion < LAST_REBUILD_SCHEMA_VERSION)
11345
+ schemaProblems.push(`schema ${schemaVersion} requires a full rebuild for current schema ${KNODIN_SCHEMA_VERSION}`);
11346
+ else if (schemaVersion < KNODIN_SCHEMA_VERSION)
11347
+ schemaProblems.push(`schema ${schemaVersion} requires migration to current schema ${KNODIN_SCHEMA_VERSION}`);
11348
+ else if (schemaVersion > KNODIN_SCHEMA_VERSION)
11349
+ schemaProblems.push(`schema ${schemaVersion} is newer than supported schema ${KNODIN_SCHEMA_VERSION}`);
10760
11350
  if (schemaProblems.length) {
10761
11351
  if (!activeDb)
10762
11352
  db.close();
@@ -10783,10 +11373,15 @@ export function createEngine() {
10783
11373
  lastIndexedHead: "",
10784
11374
  freshness: buildFreshnessEnvelope(resolved, db, verifiedAt, "repair-needed"),
10785
11375
  verification: { mode: "deep-audit", verifiedAt },
10786
- repairSteps: [
10787
- "Run `knodin repair` to migrate/rebuild damaged local schema.",
10788
- "Run `knodin status` again to verify health.",
10789
- ],
11376
+ repairSteps: schemaVersion > KNODIN_SCHEMA_VERSION
11377
+ ? [
11378
+ "Upgrade knodin to a version that supports this local graph schema.",
11379
+ "Run `knodin status` again after upgrading.",
11380
+ ]
11381
+ : [
11382
+ "Run `knodin repair` to migrate/rebuild damaged local schema.",
11383
+ "Run `knodin status` again to verify health.",
11384
+ ],
10790
11385
  };
10791
11386
  }
10792
11387
  const indexedRows = db
@@ -10908,6 +11503,7 @@ export function createEngine() {
10908
11503
  options?.signal?.removeEventListener("abort", abortShared);
10909
11504
  }
10910
11505
  }
11506
+ invalidateResolutionCaches(resolved);
10911
11507
  const operationId = crypto.randomUUID();
10912
11508
  const startedAt = Date.now();
10913
11509
  let sequence = 0;
@@ -10978,6 +11574,23 @@ export function createEngine() {
10978
11574
  phaseTotal: 1,
10979
11575
  });
10980
11576
  cancelBoundary();
11577
+ if (before.schemaVersion > KNODIN_SCHEMA_VERSION) {
11578
+ const outstandingIssues = {
11579
+ files: before.missing.files.length,
11580
+ records: before.missing.records.length,
11581
+ total: before.missing.files.length + before.missing.records.length,
11582
+ };
11583
+ emitProgress("completed", overallCompleted, "Repair stopped because the local graph schema requires a newer knodin version");
11584
+ return {
11585
+ repaired,
11586
+ before,
11587
+ after: before,
11588
+ verified: false,
11589
+ cancelled: false,
11590
+ remaining: 0,
11591
+ outstandingIssues,
11592
+ };
11593
+ }
10981
11594
  emitProgress("planning", 0, "Building the repair plan");
10982
11595
  const repairKinds = new Map();
10983
11596
  for (const file of before.missing.files)
@@ -11028,11 +11641,15 @@ export function createEngine() {
11028
11641
  skipContentReconciliation: true,
11029
11642
  skipFreshnessGuard: true,
11030
11643
  }));
11644
+ const schemaPrepared = before.schemaVersion !== KNODIN_SCHEMA_VERSION;
11645
+ if (schemaPrepared)
11646
+ committedWork = true;
11031
11647
  const inheritedPostprocessing = getMeta(db, "repairPostprocessingPending") === "1";
11032
11648
  const eligibleRepairPaths = new Set(collectRepoFiles(resolved));
11033
11649
  repairPaths = [...repairKinds.keys()]
11034
11650
  .filter((file) => repairKinds.get(file) === "stale" || fileDriftedFromIndexState(db, resolved, file))
11035
11651
  .sort(compareBytes);
11652
+ const repairAffectsTypeScriptDi = inheritedPostprocessing || repairPathsAffectTypeScriptDi(db, resolved, repairPaths);
11036
11653
  counts.skipped = Math.max(0, repairKinds.size - repairPaths.length);
11037
11654
  overallTotal = repairPaths.length + 4;
11038
11655
  emitProgress("planning", repairPaths.length, "Repair plan completed", {
@@ -11098,6 +11715,8 @@ export function createEngine() {
11098
11715
  });
11099
11716
  cancelBoundary();
11100
11717
  persistSymbolIdentities(db, resolved, inheritedPostprocessing ? undefined : repairPaths);
11718
+ if (repairAffectsTypeScriptDi)
11719
+ reconcileTypeScriptDi(db, resolved);
11101
11720
  committedWork = true;
11102
11721
  overallCompleted++;
11103
11722
  emitProgress("symbol-identities", 1, "Stable symbol identities persisted", {
@@ -11181,8 +11800,7 @@ export function createEngine() {
11181
11800
  phaseTotal: 1,
11182
11801
  });
11183
11802
  cancelBoundary();
11184
- if (repairKinds.size > 0 || hasOrphans || needsPostprocessing) {
11185
- const reconciledAt = new Date().toISOString();
11803
+ if (schemaPrepared || repairKinds.size > 0 || hasOrphans || needsPostprocessing) {
11186
11804
  // The first deep audit proves graph-content integrity. Only
11187
11805
  // after that proof may this snapshot claim the current
11188
11806
  // revision; a second deep audit proves the complete envelope.
@@ -11190,15 +11808,38 @@ export function createEngine() {
11190
11808
  after.missing.files.length === 0 &&
11191
11809
  after.missing.records.length === 0 &&
11192
11810
  Object.values(after.orphaned).every((count) => count === 0);
11193
- if (contentVerified &&
11194
- (after.freshness.state === "stale-head" || after.freshness.state === "unknown")) {
11195
- setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
11811
+ if (contentVerified) {
11812
+ db.run("BEGIN TRANSACTION;");
11813
+ try {
11814
+ if (after.freshness.state === "stale-head" || after.freshness.state === "unknown") {
11815
+ setMeta(db, "lastIndexedHead", gitHead(resolved) ?? "");
11816
+ }
11817
+ setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
11818
+ recordFreshnessBaseline(resolved, db);
11819
+ statusCache.delete(resolved);
11820
+ const candidate = await engine.status(resolved, { audit: "deep" });
11821
+ if (candidate.status === "healthy") {
11822
+ db.run("COMMIT;");
11823
+ after = candidate;
11824
+ }
11825
+ else {
11826
+ db.run("ROLLBACK;");
11827
+ statusCache.delete(resolved);
11828
+ after = await engine.status(resolved, { audit: "deep" });
11829
+ }
11830
+ }
11831
+ catch (error) {
11832
+ db.run("ROLLBACK;");
11833
+ statusCache.delete(resolved);
11834
+ throw error;
11835
+ }
11196
11836
  }
11197
- setMeta(db, "lastSuccessfulReconciliation", reconciledAt);
11198
- recordFreshnessBaseline(resolved, db);
11199
- statusCache.delete(resolved);
11200
- after = await engine.status(resolved, { audit: "deep" });
11201
11837
  }
11838
+ const outstandingIssues = {
11839
+ files: after.missing.files.length,
11840
+ records: after.missing.records.length,
11841
+ total: after.missing.files.length + after.missing.records.length,
11842
+ };
11202
11843
  const result = {
11203
11844
  repaired,
11204
11845
  before,
@@ -11206,6 +11847,7 @@ export function createEngine() {
11206
11847
  verified: after.status === "healthy",
11207
11848
  cancelled: false,
11208
11849
  remaining: 0,
11850
+ outstandingIssues,
11209
11851
  };
11210
11852
  emitProgress("completed", overallCompleted, result.verified
11211
11853
  ? "Repair completed and verified"
@@ -11226,6 +11868,11 @@ export function createEngine() {
11226
11868
  verified: false,
11227
11869
  cancelled: true,
11228
11870
  remaining,
11871
+ outstandingIssues: {
11872
+ files: after.missing.files.length,
11873
+ records: after.missing.records.length,
11874
+ total: after.missing.files.length + after.missing.records.length,
11875
+ },
11229
11876
  };
11230
11877
  }
11231
11878
  counts.failed++;
@@ -11249,6 +11896,7 @@ export function createEngine() {
11249
11896
  }
11250
11897
  },
11251
11898
  async index(repoPath, files, clean = false, options) {
11899
+ invalidateResolutionCaches(repoPath);
11252
11900
  const indexed = [];
11253
11901
  const unchanged = [];
11254
11902
  let scipReport;
@@ -11299,9 +11947,14 @@ export function createEngine() {
11299
11947
  indexed.push(relativePath);
11300
11948
  progress("indexing-files", fileIndex + 1, `Checking ${files.length.toLocaleString()} requested files`, { phaseTotal: files.length });
11301
11949
  }
11950
+ const refreshedFlows = await refreshFlowsForApexFiles(db, repoPath, indexed);
11951
+ for (const flow of refreshedFlows)
11952
+ if (!indexed.includes(flow))
11953
+ indexed.push(flow);
11302
11954
  if (indexed.length > 0) {
11303
11955
  progress("finalizing", 0, "Refreshing identities and semantic embeddings");
11304
11956
  persistSymbolIdentities(db, repoPath, indexed);
11957
+ reconcileTypeScriptDi(db, repoPath);
11305
11958
  await indexEmbeddings(db, repoPath, progress);
11306
11959
  indexGeneration++;
11307
11960
  }
@@ -11961,6 +12614,14 @@ export function createEngine() {
11961
12614
  throw new Error(`knodin query ${pattern}: includeDataFlow is unsupported`);
11962
12615
  if (options.flowVariable !== undefined && pattern !== "flow_analysis")
11963
12616
  throw new Error(`knodin query ${pattern}: flowVariable is unsupported`);
12617
+ if ((options.resourceOffset !== undefined ||
12618
+ options.resourceMaxBytes !== undefined ||
12619
+ options.resourceMaxTokens !== undefined) &&
12620
+ pattern !== "resource_reachability")
12621
+ throw new Error(`knodin query ${pattern}: resource reachability bounds are unsupported`);
12622
+ if (options.resourceOffset !== undefined &&
12623
+ (!Number.isInteger(options.resourceOffset) || options.resourceOffset < 0))
12624
+ throw new Error("knodin query resource_reachability: offset must be a non-negative integer");
11964
12625
  if (options.architectureFacets?.length && pattern !== "architecture_overview")
11965
12626
  throw new Error(`knodin query ${pattern}: architectureFacets is unsupported`);
11966
12627
  if (options.architectureFacets?.some((facet) => !["packages", "layers", "boundaries", "hotspots", "entryPoints", "languages"].includes(facet)))
@@ -11978,6 +12639,7 @@ export function createEngine() {
11978
12639
  "structural_implementations_of",
11979
12640
  "tests_for",
11980
12641
  "shortest_path",
12642
+ "cross_substrate_path",
11981
12643
  "rename_preview",
11982
12644
  "traverse",
11983
12645
  "feature_path",
@@ -11995,6 +12657,22 @@ export function createEngine() {
11995
12657
  count: 0,
11996
12658
  results: [],
11997
12659
  ...(pattern === "shortest_path" ? { path: [] } : {}),
12660
+ ...(pattern === "cross_substrate_path"
12661
+ ? {
12662
+ crossSubstratePath: {
12663
+ steps: [],
12664
+ omissions: [
12665
+ "The source endpoint is ambiguous; select a stable identity or file.",
12666
+ ],
12667
+ unsupportedCrossings: [
12668
+ "Terraform, dbt, and arbitrary Salesforce metadata crossings are not resolved.",
12669
+ ],
12670
+ budget: { stepLimit: 1, returnedSteps: 0 },
12671
+ truncated: false,
12672
+ continuation: null,
12673
+ },
12674
+ }
12675
+ : {}),
11998
12676
  ambiguity: resolved.ambiguity,
11999
12677
  };
12000
12678
  selectedTarget = resolved.selected;
@@ -12877,6 +13555,102 @@ export function createEngine() {
12877
13555
  },
12878
13556
  })));
12879
13557
  }
13558
+ case "cross_substrate_path": {
13559
+ const unsupportedCrossings = [
13560
+ "Terraform, dbt, and arbitrary Salesforce metadata crossings are not resolved.",
13561
+ ];
13562
+ const empty = (omission) => finish([], {
13563
+ crossSubstratePath: {
13564
+ steps: [],
13565
+ omissions: [omission],
13566
+ unsupportedCrossings,
13567
+ budget: { stepLimit: 1, returnedSteps: 0 },
13568
+ truncated: false,
13569
+ continuation: null,
13570
+ },
13571
+ });
13572
+ if (!to)
13573
+ return empty("Both from and to endpoints are required.");
13574
+ if (!selectedTarget)
13575
+ return empty("The source endpoint was not resolved uniquely.");
13576
+ const destination = resolveSymbolRows(db, primary.path, to, {
13577
+ identity: selector.toIdentity,
13578
+ file: selector.toFile,
13579
+ kind: selector.toKind,
13580
+ });
13581
+ if (destination.ambiguity)
13582
+ return finish([], {
13583
+ ambiguity: destination.ambiguity,
13584
+ crossSubstratePath: {
13585
+ steps: [],
13586
+ omissions: [
13587
+ "The destination endpoint is ambiguous; select a stable identity or file.",
13588
+ ],
13589
+ unsupportedCrossings,
13590
+ budget: { stepLimit: 1, returnedSteps: 0 },
13591
+ truncated: false,
13592
+ continuation: null,
13593
+ },
13594
+ });
13595
+ if (!destination.selected)
13596
+ return empty("The destination endpoint was not resolved uniquely.");
13597
+ if (selectedTarget.kind !== "salesforce-metadata" ||
13598
+ !selectedTarget.filePath.endsWith(".flow-meta.xml") ||
13599
+ !destination.selected.filePath.endsWith(".cls"))
13600
+ return empty("The endpoints are not a supported Salesforce Flow-to-Apex boundary.");
13601
+ const className = path.basename(destination.selected.filePath, ".cls");
13602
+ const actionName = `${className}.${destination.selected.name}`;
13603
+ let flowEvidence = "";
13604
+ try {
13605
+ const flowSource = fs.readFileSync(path.join(primary.path, selectedTarget.filePath), "utf8");
13606
+ const matchingActions = [
13607
+ ...flowSource.matchAll(/<actionCalls>([\s\S]*?)<\/actionCalls>/g),
13608
+ ]
13609
+ .filter((match) => /<actionType>apex<\/actionType>/.test(match[1]))
13610
+ .filter((match) => staticXmlTagValues(match[1], "actionName").some((entry) => entry.value === actionName));
13611
+ if (matchingActions.length === 1)
13612
+ flowEvidence = matchingActions[0][0].trim();
13613
+ }
13614
+ catch { }
13615
+ const apexEvidence = findUniqueInvocableApexMethod(className, destination.selected.name, primary.path)
13616
+ ?.evidence ?? "";
13617
+ if (!flowEvidence)
13618
+ return empty("No exact literal Apex action for the selected method exists in the Flow.");
13619
+ if (!apexEvidence)
13620
+ return empty("The selected Apex method is not one unique local @InvocableMethod.");
13621
+ if (!selectedTarget.identity || !destination.selected.identity)
13622
+ return empty("Exact endpoint identity is unavailable.");
13623
+ return finish([], {
13624
+ crossSubstratePath: {
13625
+ steps: [
13626
+ {
13627
+ kind: "flow_apex_action",
13628
+ from: {
13629
+ symbol: selectedTarget.name,
13630
+ file: selectedTarget.filePath,
13631
+ identity: symbolIdentity(primary.path, selectedTarget),
13632
+ substrate: "salesforce-flow",
13633
+ },
13634
+ to: {
13635
+ symbol: destination.selected.name,
13636
+ file: destination.selected.filePath,
13637
+ identity: symbolIdentity(primary.path, destination.selected),
13638
+ substrate: "salesforce-apex",
13639
+ },
13640
+ provenance: "EXTRACTED",
13641
+ confidence: 1,
13642
+ freshness: stalenessFor(resolvedRepoPath),
13643
+ evidence: { flow: flowEvidence, apex: apexEvidence },
13644
+ },
13645
+ ],
13646
+ omissions: [],
13647
+ unsupportedCrossings,
13648
+ budget: { stepLimit: 1, returnedSteps: 1 },
13649
+ truncated: false,
13650
+ continuation: null,
13651
+ },
13652
+ });
13653
+ }
12880
13654
  case "shortest_path": {
12881
13655
  if (!to)
12882
13656
  return finish([], { path: [] });
@@ -12890,7 +13664,7 @@ export function createEngine() {
12890
13664
  return finish([], { path: [], ambiguity: destination.ambiguity });
12891
13665
  if (selectedTarget && destination.selected) {
12892
13666
  const edges = db
12893
- .query("SELECT callerSymbol, callerFile, calleeSymbol, calleeFile, callerIdentity, calleeIdentity FROM \"references\" WHERE callerSymbol IS NOT NULL AND calleeFile IS NOT NULL AND kind = 'call'")
13667
+ .query("SELECT callerSymbol, callerFile, calleeSymbol, calleeFile, callerIdentity, calleeIdentity, kind FROM \"references\" WHERE callerSymbol IS NOT NULL AND calleeFile IS NOT NULL AND kind IN ('call', 'di_binding', 'di_resolution', 'flow_apex_action')")
12894
13668
  .all();
12895
13669
  const key = (name, file, identity) => `${name}\0${file}\0${identity ?? ""}`;
12896
13670
  const start = key(selectedTarget.name, selectedTarget.filePath, selectedTarget.identity);
@@ -13339,6 +14113,57 @@ export function createEngine() {
13339
14113
  ...(truncated ? { truncated: true } : {}),
13340
14114
  });
13341
14115
  }
14116
+ case "resource_reachability": {
14117
+ const indexed = db
14118
+ .query("SELECT filePath FROM index_state ORDER BY filePath")
14119
+ .all()
14120
+ .filter(({ filePath }) => /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(filePath));
14121
+ let diskDrifted = indexed.some(({ filePath }) => fileDriftedFromIndexState(db, resolvedRepoPath, filePath));
14122
+ const cachedCorpus = resourceCorpusCache.get(resolvedRepoPath);
14123
+ let inputs;
14124
+ let diskFingerprint;
14125
+ if (!diskDrifted && cachedCorpus?.generation === indexGeneration) {
14126
+ inputs = cachedCorpus.inputs;
14127
+ diskFingerprint = cachedCorpus.fingerprint;
14128
+ }
14129
+ else {
14130
+ inputs = indexed.flatMap(({ filePath }) => {
14131
+ const absolute = path.join(resolvedRepoPath, filePath);
14132
+ try {
14133
+ const content = fs.readFileSync(absolute, "utf8");
14134
+ if (fileDriftedFromIndexState(db, resolvedRepoPath, filePath))
14135
+ diskDrifted = true;
14136
+ return [{ path: filePath, content }];
14137
+ }
14138
+ catch {
14139
+ diskDrifted = true;
14140
+ return [];
14141
+ }
14142
+ });
14143
+ diskFingerprint = resourceFingerprint(inputs);
14144
+ if (!diskDrifted)
14145
+ setBoundedCache(resourceCorpusCache, resolvedRepoPath, { generation: indexGeneration, inputs, fingerprint: diskFingerprint }, RESOURCE_CORPUS_CACHE_LIMIT);
14146
+ }
14147
+ // Disk state is part of the cache identity. A generation-only key can
14148
+ // return a previously fresh result during the freshness-probe TTL after
14149
+ // an external edit. index_state is the graph's persisted disk snapshot;
14150
+ // any mismatch is passed into the analyzer as a fail-closed expectation.
14151
+ const cacheKey = `${resolvedRepoPath}\0${diskFingerprint}\0${diskDrifted ? "drifted" : "matched"}\0${cap}\0${options.resourceOffset ?? 0}\0${options.resourceMaxBytes ?? 65_536}\0${options.resourceMaxTokens ?? 16_384}`;
14152
+ const cached = resourceReachabilityCache.get(cacheKey);
14153
+ if (cached?.generation === indexGeneration) {
14154
+ const result = structuredClone(cached.result);
14155
+ return finish([], { count: result.paths.length, resourceReachability: result });
14156
+ }
14157
+ const result = analyzeResourceReachability(inputs, {
14158
+ maxItems: cap,
14159
+ maxBytes: options.resourceMaxBytes,
14160
+ maxTokens: options.resourceMaxTokens,
14161
+ offset: options.resourceOffset,
14162
+ expectedFingerprint: diskDrifted ? `indexed:${diskFingerprint}` : diskFingerprint,
14163
+ });
14164
+ setBoundedCache(resourceReachabilityCache, cacheKey, { generation: indexGeneration, result: structuredClone(result) }, RESOURCE_REACHABILITY_CACHE_LIMIT);
14165
+ return finish([], { count: result.paths.length, resourceReachability: result });
14166
+ }
13342
14167
  case "flow_analysis": {
13343
14168
  // C8 deliberately does not persist a PDG. This is a bounded, local
13344
14169
  // source read for one resolved TS/JS function-like symbol only.
@@ -14194,11 +15019,38 @@ export function createEngine() {
14194
15019
  return result;
14195
15020
  },
14196
15021
  async close() {
14197
- for (const watcher of watchers.values()) {
14198
- await watcher.close();
14199
- }
14200
- watchers.clear();
14201
- for (const queue of watchQueues.values()) {
15022
+ if (closed)
15023
+ return;
15024
+ closed = true;
15025
+ const releasable = new Set();
15026
+ for (const repo of ownedRepositories) {
15027
+ const owners = repositoryEngineOwners.get(repo);
15028
+ owners?.delete(owner);
15029
+ if (!owners?.size) {
15030
+ repositoryEngineOwners.delete(repo);
15031
+ releasable.add(repo);
15032
+ }
15033
+ }
15034
+ // When the final tracked engine closes, also reclaim legacy/federated
15035
+ // resources that predate ownership registration.
15036
+ if (repositoryEngineOwners.size === 0) {
15037
+ for (const repo of watchers.keys())
15038
+ releasable.add(repo);
15039
+ for (const repo of watchQueues.keys())
15040
+ releasable.add(repo);
15041
+ for (const repo of dbInstances.keys())
15042
+ releasable.add(repo);
15043
+ }
15044
+ for (const repo of releasable) {
15045
+ const watcher = watchers.get(repo);
15046
+ if (watcher)
15047
+ await watcher.close();
15048
+ watchers.delete(repo);
15049
+ }
15050
+ for (const repo of releasable) {
15051
+ const queue = watchQueues.get(repo);
15052
+ if (!queue)
15053
+ continue;
14202
15054
  queue.closed = true;
14203
15055
  if (queue.timer) {
14204
15056
  clearTimeout(queue.timer);
@@ -14209,13 +15061,15 @@ export function createEngine() {
14209
15061
  await queue.drain();
14210
15062
  if (queue.pending.size > 0)
14211
15063
  await queue.drain();
15064
+ watchQueues.delete(repo);
14212
15065
  }
14213
- watchQueues.clear();
14214
- for (const db of dbInstances.values()) {
14215
- db.close();
15066
+ for (const repo of releasable) {
15067
+ const db = dbInstances.get(repo);
15068
+ if (db)
15069
+ db.close();
15070
+ dbInstances.delete(repo);
15071
+ initPromises.delete(repo);
14216
15072
  }
14217
- dbInstances.clear();
14218
- initPromises.clear();
14219
15073
  freshnessProbes.clear();
14220
15074
  mapCache.clear();
14221
15075
  minimalMapCache.clear();
@@ -14230,9 +15084,11 @@ export function createEngine() {
14230
15084
  annIndexCache.clear();
14231
15085
  searchMetadataCache.clear();
14232
15086
  searchCommunityLookupCache.clear();
15087
+ resourceCorpusCache.clear();
15088
+ resourceReachabilityCache.clear();
14233
15089
  packageMapCache.clear();
14234
15090
  reExportCache.clear();
14235
15091
  },
14236
15092
  };
14237
- return withStaleness(engine);
15093
+ return withStaleness(engine, claimRepository);
14238
15094
  }