@zokizuan/satori-mcp 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { EmbeddingProviderErrorCode } from "@zokizuan/satori-core";
1
2
  export type VectorBackendDiagnosticCode = "ZILLIZ_CLUSTER_STOPPED" | "VECTOR_BACKEND_AUTH_FAILED" | "VECTOR_BACKEND_UNREACHABLE" | "VECTOR_BACKEND_TIMEOUT" | "VECTOR_BACKEND_CONNECTION_CLOSED";
2
3
  export interface VectorBackendDiagnostic {
3
4
  code: VectorBackendDiagnosticCode;
@@ -11,5 +12,21 @@ export interface VectorBackendDiagnostic {
11
12
  };
12
13
  };
13
14
  }
15
+ export type SemanticPassFailureDiagnostic = {
16
+ passId: "primary" | "expanded";
17
+ errorName: "Error" | "TypeError" | "RangeError" | "ReferenceError" | "SyntaxError" | "AggregateError" | "EmbeddingProviderError" | "NonErrorThrow";
18
+ code: VectorBackendDiagnosticCode | EmbeddingProviderErrorCode | "UNCLASSIFIED_SEARCH_PASS_FAILURE";
19
+ classifier: "embedding_provider" | "vector_backend" | "unclassified";
20
+ retryable?: boolean;
21
+ };
22
+ export declare function buildSemanticPassFailureDiagnostic(input: {
23
+ passId: SemanticPassFailureDiagnostic["passId"];
24
+ error: unknown;
25
+ embeddingDiagnostic: {
26
+ code: EmbeddingProviderErrorCode;
27
+ retryable: boolean;
28
+ } | null;
29
+ vectorDiagnostic: VectorBackendDiagnostic | null;
30
+ }): SemanticPassFailureDiagnostic;
14
31
  export declare function classifyVectorBackendError(error: unknown): VectorBackendDiagnostic | null;
15
32
  //# sourceMappingURL=backend-diagnostics.d.ts.map
@@ -1,3 +1,47 @@
1
+ const ALLOWED_SEARCH_PASS_ERROR_NAMES = new Set([
2
+ "Error",
3
+ "TypeError",
4
+ "RangeError",
5
+ "ReferenceError",
6
+ "SyntaxError",
7
+ "AggregateError",
8
+ "EmbeddingProviderError",
9
+ ]);
10
+ function classifySearchPassErrorName(error) {
11
+ if (!(error instanceof Error)) {
12
+ return "NonErrorThrow";
13
+ }
14
+ return ALLOWED_SEARCH_PASS_ERROR_NAMES.has(error.name)
15
+ ? error.name
16
+ : "Error";
17
+ }
18
+ export function buildSemanticPassFailureDiagnostic(input) {
19
+ const errorName = classifySearchPassErrorName(input.error);
20
+ if (input.embeddingDiagnostic) {
21
+ return {
22
+ passId: input.passId,
23
+ errorName,
24
+ code: input.embeddingDiagnostic.code,
25
+ classifier: "embedding_provider",
26
+ retryable: input.embeddingDiagnostic.retryable,
27
+ };
28
+ }
29
+ if (input.vectorDiagnostic) {
30
+ return {
31
+ passId: input.passId,
32
+ errorName,
33
+ code: input.vectorDiagnostic.code,
34
+ classifier: "vector_backend",
35
+ retryable: input.vectorDiagnostic.hints.backend.retryable,
36
+ };
37
+ }
38
+ return {
39
+ passId: input.passId,
40
+ errorName,
41
+ code: "UNCLASSIFIED_SEARCH_PASS_FAILURE",
42
+ classifier: "unclassified",
43
+ };
44
+ }
1
45
  function collectErrorText(value, depth = 0) {
2
46
  if (depth > 2 || value == null) {
3
47
  return "";
@@ -84,6 +84,7 @@ export type CallGraphQueryResponse = CallGraphResponseSupported | CallGraphRespo
84
84
  export interface CallGraphDeltaPolicy {
85
85
  shouldRebuild(changedFiles: string[]): boolean;
86
86
  }
87
+ export declare const DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT = 50;
87
88
  export declare class SupportedSourceDeltaPolicy implements CallGraphDeltaPolicy {
88
89
  shouldRebuild(changedFiles: string[]): boolean;
89
90
  }
@@ -9,7 +9,7 @@ const QUERY_SUPPORTED_EXTENSIONS = new Set(getSupportedExtensionsForCapability('
9
9
  const QUERY_SUPPORTED_LANGUAGE_IDS = getSupportedLanguageIdsForCapability('callGraphQuery');
10
10
  const DEFAULT_IGNORE_PATTERNS = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.next/**'];
11
11
  const DEFAULT_CALL_GRAPH_NOTE_LIMIT = 200;
12
- const DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT = 50;
12
+ export const DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT = 50;
13
13
  const CALL_KEYWORDS = new Set([
14
14
  'if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'typeof', 'function', 'class', 'def', 'await', 'with', 'from', 'import',
15
15
  ]);
@@ -296,6 +296,7 @@ export declare class ToolHandlers {
296
296
  searchPassCount: number;
297
297
  searchPassSuccessCount: number;
298
298
  searchPassFailureCount: number;
299
+ semanticPassFailures?: import("./backend-diagnostics.js").SemanticPassFailureDiagnostic[];
299
300
  rerankerAttempted: boolean;
300
301
  rerankerUsed: boolean;
301
302
  tool?: undefined;
@@ -336,6 +337,7 @@ export declare class ToolHandlers {
336
337
  searchPassCount: number;
337
338
  searchPassSuccessCount: number;
338
339
  searchPassFailureCount: number;
340
+ semanticPassFailures?: import("./backend-diagnostics.js").SemanticPassFailureDiagnostic[];
339
341
  rerankerAttempted: boolean;
340
342
  rerankerUsed: boolean;
341
343
  tool?: undefined;
@@ -2298,6 +2298,21 @@ export class ToolHandlers {
2298
2298
  isError: true,
2299
2299
  };
2300
2300
  }
2301
+ const parsedOperators = this.searchQuerySupport.parseSearchOperators(input.query);
2302
+ if (parsedOperators.semanticQuery.trim().length === 0) {
2303
+ const payload = this.buildInvalidSearchRequestPayload({
2304
+ path: input.path,
2305
+ query: input.query,
2306
+ scope: input.scope,
2307
+ groupBy: input.groupBy,
2308
+ resultMode: input.resultMode,
2309
+ limit: input.limit,
2310
+ }, 'Operator-only search requires semantic text or a positive must:, path:, or lang: value.');
2311
+ return {
2312
+ content: [{ type: "text", text: this.stringifyToolJson(payload) }],
2313
+ isError: true,
2314
+ };
2315
+ }
2301
2316
  const searchDiagnostics = {
2302
2317
  queryLength: input.query.length,
2303
2318
  limitRequested: input.limit,
@@ -2425,9 +2440,16 @@ export class ToolHandlers {
2425
2440
  thresholdMs: SEARCH_FRESHNESS_THRESHOLD_MS,
2426
2441
  });
2427
2442
  }
2428
- return this.syncManager.ensureFreshness(effectiveRoot, exactSourceComparisonRequired ? 0 : SEARCH_FRESHNESS_THRESHOLD_MS, preparedRead?.vectorReceipt
2429
- ? { preparedVectorReceipt: preparedRead.vectorReceipt }
2430
- : {});
2443
+ return this.syncManager.ensureFreshness(effectiveRoot, exactSourceComparisonRequired ? 0 : SEARCH_FRESHNESS_THRESHOLD_MS, {
2444
+ ...(preparedRead?.vectorReceipt
2445
+ ? { preparedVectorReceipt: preparedRead.vectorReceipt }
2446
+ : {}),
2447
+ ...(exactSourceComparisonRequired
2448
+ ? {
2449
+ exactSourceComparisonPaths: Array.from(changedFilesState.files).sort(),
2450
+ }
2451
+ : {}),
2452
+ });
2431
2453
  }),
2432
2454
  noteFreshnessMode: (mode) => {
2433
2455
  searchDiagnostics.freshnessMode = mode;
@@ -2470,7 +2492,8 @@ export class ToolHandlers {
2470
2492
  }
2471
2493
  }
2472
2494
  const sourceFreshnessWasEstablished = freshnessDecision.mode === 'synced'
2473
- || freshnessDecision.mode === 'reconciled_ignore_change';
2495
+ || freshnessDecision.mode === 'reconciled_ignore_change'
2496
+ || freshnessDecision.mode === 'skipped_source_unchanged';
2474
2497
  const checkpointWarningAlreadyPresent = frontDoorWarnings.includes(WARNING_CODES.SOURCE_FRESHNESS_CHECKPOINT_UNAVAILABLE);
2475
2498
  const partialIndexSearchWarnings = !sourceFreshnessWasEstablished
2476
2499
  && !checkpointWarningAlreadyPresent
@@ -2487,7 +2510,6 @@ export class ToolHandlers {
2487
2510
  console.log(`${rootTag} Query metadata: length=${input.query.length}, requestId=${requestId}`);
2488
2511
  console.log(`${rootTag} Indexing status: Completed`);
2489
2512
  console.log(`${rootTag} 🧠 Using embedding provider: ${encoderEngine.getProvider()} for search`);
2490
- const parsedOperators = this.searchQuerySupport.parseSearchOperators(input.query);
2491
2513
  const semanticQuery = parsedOperators.semanticQuery;
2492
2514
  const queryPlan = this.searchQuerySupport.buildSearchQueryPlan(semanticQuery, parsedOperators);
2493
2515
  searchDiagnostics.routeKind = queryPlan.route.kind;
@@ -2537,6 +2559,7 @@ export class ToolHandlers {
2537
2559
  const initialDirtyFilesNotFreshened = initialObservedChangedFilesState.available
2538
2560
  && initialObservedChangedFilesCount > 0
2539
2561
  && freshnessDecision.mode !== 'synced'
2562
+ && freshnessDecision.mode !== 'skipped_source_unchanged'
2540
2563
  && freshnessDecision.mode !== 'reconciled_ignore_change';
2541
2564
  const initialRankingProvenance = {
2542
2565
  semanticPassesUsed: [],
@@ -2755,6 +2778,14 @@ export class ToolHandlers {
2755
2778
  resultMode: input.resultMode,
2756
2779
  limit: input.limit
2757
2780
  }, "Search backend failed: all semantic search passes failed. Retry and verify embedding/vector backends are reachable.", "not_ready", "search_backend_failed");
2781
+ if (debugMode === 'full') {
2782
+ payload.hints = {
2783
+ ...(payload.hints || {}),
2784
+ debugSearch: {
2785
+ semanticPassFailures: execution.semanticPassFailures.map((failure) => ({ ...failure })),
2786
+ },
2787
+ };
2788
+ }
2758
2789
  return {
2759
2790
  content: [{ type: "text", text: this.stringifyToolJson(payload) }],
2760
2791
  isError: true,
@@ -3025,7 +3056,7 @@ export class ToolHandlers {
3025
3056
  includeSummary: true,
3026
3057
  buildEnvelope: (results, disclosure) => {
3027
3058
  const recommendedNextAction = entry.recommendedActions[lookup.nextOffset] ?? null;
3028
- const noiseMitigationHint = this.searchQuerySupport.buildNoiseMitigationHint(entry.canonicalRoot, results.map((result) => result.target.file), entry.baseEnvelope.scope);
3059
+ const noiseMitigationHint = this.searchQuerySupport.buildNoiseMitigationHint(entry.canonicalRoot, results.map((result) => result.target.file), entry.baseEnvelope.scope, this.searchQuerySupport.parseSearchOperators(entry.baseEnvelope.query));
3029
3060
  const generatedArtifactsHint = this.buildGeneratedArtifactsVerificationHint(entry.canonicalRoot, results.map((result) => ({
3030
3061
  file: result.target.file,
3031
3062
  span: result.target.span,
@@ -766,6 +766,11 @@ export class ManageIndexingHandlers {
766
766
  }
767
767
  this.host.mutationLeaseCoordinator.publishWhileCurrent(mutationLease, publish);
768
768
  },
769
+ publicationAuthority: {
770
+ ownerId: mutationLease.ownerId,
771
+ generation: mutationLease.generation,
772
+ operationId: mutationLease.operationId,
773
+ },
769
774
  } : {}),
770
775
  });
771
776
  if (mutationLease) {
@@ -781,16 +786,41 @@ export class ManageIndexingHandlers {
781
786
  ...result.proof,
782
787
  navigation: {
783
788
  status: "unproven",
784
- basis: "call_graph_rebuild_in_progress",
789
+ basis: "generation_proof_in_progress",
785
790
  },
786
791
  };
787
- await this.host.rebuildCallGraphForIndex(absolutePath, assertMutationCurrent);
792
+ const proven = await this.host.context.proveIndexedGeneration(absolutePath);
793
+ if (!proven
794
+ || proven.collectionName !== result.collectionName
795
+ || proven.marker.indexedFiles !== result.indexedFiles
796
+ || proven.exactPayloadCount !== result.totalChunks) {
797
+ throw new Error(`Repair completion for '${absolutePath}' did not match an exact proven generation.`);
798
+ }
799
+ if (result.activatedGeneration) {
800
+ const activated = result.activatedGeneration;
801
+ if (activated.collectionName !== proven.collectionName
802
+ || activated.markerRunId !== proven.marker.runId
803
+ || activated.relationshipVersion !== this.host.runtimeFingerprint.relationshipVersion
804
+ || activated.navigation.generationId !== proven.navigation.generationId
805
+ || activated.navigation.sealHash !== proven.navigation.navigationSealHash
806
+ || activated.navigation.symbolRegistryManifestHash
807
+ !== proven.navigation.symbolRegistryManifestHash
808
+ || activated.navigation.relationshipManifestHash
809
+ !== proven.navigation.relationshipManifestHash) {
810
+ throw new Error(`Relationship-only repair for '${absolutePath}' did not prove its activated navigation generation.`);
811
+ }
812
+ const checkpoint = await this.host.context.inspectSourceFreshnessCheckpoint(absolutePath, proven.collectionName, proven);
813
+ if (checkpoint.status !== "valid"
814
+ || checkpoint.documentDigest !== activated.sourceCheckpointDocumentDigest) {
815
+ throw new Error(`Relationship-only repair for '${absolutePath}' did not preserve its source checkpoint identity.`);
816
+ }
817
+ }
788
818
  assertMutationCurrent?.();
789
819
  lastRepairProof = {
790
820
  ...result.proof,
791
821
  navigation: {
792
822
  status: "matched",
793
- basis: "navigation_and_call_graph_rebuilt",
823
+ basis: "activated_generation_proven",
794
824
  },
795
825
  };
796
826
  try {
@@ -853,7 +883,7 @@ export class ManageIndexingHandlers {
853
883
  }
854
884
  catch (error) {
855
885
  console.error("Error in handleRepairIndex:", error);
856
- if (lastRepairProof?.navigation.basis === "call_graph_rebuild_in_progress") {
886
+ if (lastRepairProof?.navigation.basis === "generation_proof_in_progress") {
857
887
  lastRepairProof = {
858
888
  ...lastRepairProof,
859
889
  navigation: {
@@ -528,7 +528,6 @@ export class NavigationHandlers {
528
528
  const exactRegistrySymbols = findExactRegistrySymbols({
529
529
  symbols: registryState.symbols,
530
530
  symbolIdExact: symbolRef.symbolId,
531
- symbolLabelExact: symbolRef.symbolLabel,
532
531
  });
533
532
  if (exactRegistrySymbols.length === 0) {
534
533
  const payload = this.host.withProofDebugHint({
@@ -140,21 +140,103 @@ function findPythonDefinitionIndexNearSpan(lines, symbol) {
140
140
  const windowEnd = Math.min(lines.length, Math.max(spanEndExclusive, startIndex + 48));
141
141
  return findPythonDefinitionIndexByName(lines, symbol.name, windowStart, windowEnd);
142
142
  }
143
+ function isPythonDecoratorBlock(lines, startIndex, definitionIndex, indent) {
144
+ let delimiterDepth = 0;
145
+ let quote = null;
146
+ let tripleQuoted = false;
147
+ let escaped = false;
148
+ let expectsDecorator = true;
149
+ for (let index = startIndex; index < definitionIndex; index += 1) {
150
+ const line = lines[index] || "";
151
+ const trimmed = line.trim();
152
+ if (expectsDecorator) {
153
+ if (trimmed.length === 0
154
+ || !trimmed.startsWith("@")
155
+ || countPythonIndent(line) !== indent) {
156
+ return false;
157
+ }
158
+ expectsDecorator = false;
159
+ }
160
+ else if (trimmed.length === 0 && delimiterDepth === 0 && !tripleQuoted) {
161
+ return false;
162
+ }
163
+ for (let offset = 0; offset < line.length; offset += 1) {
164
+ const char = line[offset];
165
+ const nextTwo = line.slice(offset, offset + 3);
166
+ if (quote) {
167
+ if (escaped) {
168
+ escaped = false;
169
+ continue;
170
+ }
171
+ if (char === "\\") {
172
+ escaped = true;
173
+ continue;
174
+ }
175
+ if (tripleQuoted && nextTwo === quote.repeat(3)) {
176
+ quote = null;
177
+ tripleQuoted = false;
178
+ offset += 2;
179
+ continue;
180
+ }
181
+ if (!tripleQuoted && char === quote) {
182
+ quote = null;
183
+ }
184
+ continue;
185
+ }
186
+ if (char === "#") {
187
+ break;
188
+ }
189
+ if (nextTwo === "'''" || nextTwo === "\"\"\"") {
190
+ quote = char;
191
+ tripleQuoted = true;
192
+ offset += 2;
193
+ continue;
194
+ }
195
+ if (char === "'" || char === "\"") {
196
+ quote = char;
197
+ continue;
198
+ }
199
+ if (char === "(" || char === "[" || char === "{") {
200
+ delimiterDepth += 1;
201
+ continue;
202
+ }
203
+ if (char === ")" || char === "]" || char === "}") {
204
+ delimiterDepth -= 1;
205
+ if (delimiterDepth < 0) {
206
+ return false;
207
+ }
208
+ }
209
+ }
210
+ const explicitContinuation = line.trimEnd().endsWith("\\");
211
+ if (!quote && delimiterDepth === 0 && !explicitContinuation) {
212
+ expectsDecorator = true;
213
+ }
214
+ else if (quote && !tripleQuoted) {
215
+ return false;
216
+ }
217
+ escaped = false;
218
+ }
219
+ return expectsDecorator && delimiterDepth === 0 && quote === null;
220
+ }
143
221
  function findPythonDecoratedDefinitionStart(lines, definitionIndex) {
144
222
  const indent = countPythonIndent(lines[definitionIndex] || "");
145
- let startIndex = definitionIndex;
223
+ const candidates = [];
146
224
  for (let index = definitionIndex - 1; index >= 0; index -= 1) {
147
225
  const line = lines[index] || "";
148
226
  const trimmed = line.trim();
149
- if (trimmed.length === 0) {
227
+ if (trimmed.length === 0 && candidates.length === 0) {
150
228
  break;
151
229
  }
152
- if (!trimmed.startsWith("@") || countPythonIndent(line) !== indent) {
153
- break;
230
+ if (trimmed.startsWith("@") && countPythonIndent(line) === indent) {
231
+ candidates.push(index);
232
+ }
233
+ }
234
+ for (const candidate of candidates.reverse()) {
235
+ if (isPythonDecoratorBlock(lines, candidate, definitionIndex, indent)) {
236
+ return candidate;
154
237
  }
155
- startIndex = index;
156
238
  }
157
- return startIndex;
239
+ return definitionIndex;
158
240
  }
159
241
  function findPythonSourceBackedBlockEnd(lines, definitionIndex, indent) {
160
242
  let lastContentLine = definitionIndex + 1;
@@ -40,8 +40,6 @@ export type RelationshipBackedCallGraphResult = {
40
40
  };
41
41
  hints?: Record<string, unknown>;
42
42
  };
43
- /** True when a repo-relative path looks like a test/fixture site (not production call graph signal). */
44
- export declare function isTestOrFixtureCallerFile(file: string): boolean;
45
43
  /**
46
44
  * Prefer a single unique suppressed inbound caller *site* file for recovery search.
47
45
  * Prefer production files when both production and test sites exist.
@@ -58,6 +56,8 @@ export declare class RelationshipBackedCallGraph {
58
56
  private sortNodes;
59
57
  private compareEdges;
60
58
  private sortEdges;
59
+ private sortTestReferences;
60
+ private buildTestReferences;
61
61
  private sortNotes;
62
62
  private mapRelationshipConfidence;
63
63
  private createNode;
@@ -1,4 +1,5 @@
1
- import { compareContractStrings, getGraphNeighbors, } from "@zokizuan/satori-core";
1
+ import { compareContractStrings, getGraphNeighbors, getRelationshipsForSymbol, isTestOrFixturePath, } from "@zokizuan/satori-core";
2
+ import { DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT } from "./call-graph.js";
2
3
  import { buildSourceBackedPythonCalleeFallback, buildSourceBackedPythonCallerFallback, } from "./python-call-fallback.js";
3
4
  import { buildInboundNotesOnlySearchQuery } from "./search-response-helpers.js";
4
5
  function compareNullableNumbersAsc(a, b) {
@@ -11,24 +12,6 @@ function compareNullableStringsAsc(a, b) {
11
12
  const right = typeof b === "string" ? b : "";
12
13
  return compareContractStrings(left, right);
13
14
  }
14
- /** True when a repo-relative path looks like a test/fixture site (not production call graph signal). */
15
- export function isTestOrFixtureCallerFile(file) {
16
- const normalized = file.trim().replace(/\\/g, "/");
17
- if (!normalized) {
18
- return false;
19
- }
20
- const base = normalized.split("/").pop() || normalized;
21
- if (/\.(test|spec)\.[cm]?[jt]sx?$/i.test(base)) {
22
- return true;
23
- }
24
- if (/(^|\/)(__tests__|__mocks__|fixtures|testdata|test-data)(\/|$)/i.test(normalized)) {
25
- return true;
26
- }
27
- if (/(^|\/)tests?(\/|$)/i.test(normalized) && !/(^|\/)packages\/[^/]+\/src\//i.test(normalized)) {
28
- return true;
29
- }
30
- return false;
31
- }
32
15
  /**
33
16
  * Prefer a single unique suppressed inbound caller *site* file for recovery search.
34
17
  * Prefer production files when both production and test sites exist.
@@ -49,7 +32,7 @@ export function uniqueInboundCallerSiteFile(notes) {
49
32
  continue;
50
33
  }
51
34
  allSites.add(file);
52
- if (!isTestOrFixtureCallerFile(file)) {
35
+ if (!isTestOrFixturePath(file)) {
53
36
  productionSites.add(file);
54
37
  }
55
38
  }
@@ -76,7 +59,7 @@ export function prioritizeInboundSuppressedNotes(notes) {
76
59
  continue;
77
60
  }
78
61
  const file = typeof note.file === "string" ? note.file : "";
79
- if (isTestOrFixtureCallerFile(file)) {
62
+ if (isTestOrFixturePath(file)) {
80
63
  testCallerNotes.push(note);
81
64
  }
82
65
  else {
@@ -138,6 +121,71 @@ export class RelationshipBackedCallGraph {
138
121
  sortEdges(edges) {
139
122
  return [...edges].sort((a, b) => this.compareEdges(a, b));
140
123
  }
124
+ sortTestReferences(references) {
125
+ return [...references].sort((a, b) => {
126
+ const fileCmp = compareNullableStringsAsc(a.file, b.file);
127
+ if (fileCmp !== 0)
128
+ return fileCmp;
129
+ const startCmp = compareNullableNumbersAsc(a.span?.startLine, b.span?.startLine);
130
+ if (startCmp !== 0)
131
+ return startCmp;
132
+ const labelCmp = compareNullableStringsAsc(a.symbolLabel, b.symbolLabel);
133
+ if (labelCmp !== 0)
134
+ return labelCmp;
135
+ const symbolCmp = compareNullableStringsAsc(a.symbolId, b.symbolId);
136
+ if (symbolCmp !== 0)
137
+ return symbolCmp;
138
+ const targetCmp = compareNullableStringsAsc(a.targetSymbolId, b.targetSymbolId);
139
+ if (targetCmp !== 0)
140
+ return targetCmp;
141
+ const siteFileCmp = compareNullableStringsAsc(a.site?.file, b.site?.file);
142
+ if (siteFileCmp !== 0)
143
+ return siteFileCmp;
144
+ return compareNullableNumbersAsc(a.site?.startLine, b.site?.startLine);
145
+ });
146
+ }
147
+ buildTestReferences(records, registry, targetSymbolId) {
148
+ const referencesByKey = new Map();
149
+ for (const record of records) {
150
+ if (record.type !== "TESTS"
151
+ || !record.sourceInstanceId
152
+ || record.targetInstanceId !== targetSymbolId) {
153
+ continue;
154
+ }
155
+ const source = registry.symbolsByInstanceId.get(record.sourceInstanceId);
156
+ if (!source) {
157
+ continue;
158
+ }
159
+ const startLine = record.span?.startLine ?? source.span.startLine;
160
+ const reference = {
161
+ file: source.file,
162
+ symbolId: source.symbolInstanceId,
163
+ symbolLabel: source.label,
164
+ span: {
165
+ startLine: source.span.startLine,
166
+ endLine: source.span.endLine,
167
+ },
168
+ site: {
169
+ file: record.file,
170
+ startLine,
171
+ ...(record.span?.endLine ? { endLine: record.span.endLine } : {}),
172
+ },
173
+ targetSymbolId,
174
+ kind: "call",
175
+ confidence: this.mapRelationshipConfidence(record.confidence),
176
+ };
177
+ const key = [
178
+ reference.symbolId,
179
+ reference.targetSymbolId,
180
+ reference.kind,
181
+ reference.site.file,
182
+ reference.site.startLine,
183
+ ].join("\0");
184
+ referencesByKey.set(key, reference);
185
+ }
186
+ return this.sortTestReferences([...referencesByKey.values()])
187
+ .slice(0, DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT);
188
+ }
141
189
  sortNotes(notes) {
142
190
  return [...notes].sort((a, b) => {
143
191
  const fileCmp = compareNullableStringsAsc(a.file, b.file);
@@ -243,6 +291,19 @@ export class RelationshipBackedCallGraph {
243
291
  if (neighbors.status !== "ok") {
244
292
  return null;
245
293
  }
294
+ const testRelationshipResult = await getRelationshipsForSymbol({
295
+ normalizedRootPath: input.codebaseRoot,
296
+ generationId: input.generationId,
297
+ expectedSymbolRegistryManifestHash: input.registryManifestHash,
298
+ navigationStore: this.host.navigationStore,
299
+ targetInstanceId: input.resolvedSymbol.symbolInstanceId,
300
+ direction: "callers",
301
+ types: ["TESTS"],
302
+ });
303
+ if (testRelationshipResult.status !== "ok") {
304
+ return null;
305
+ }
306
+ const testReferences = this.buildTestReferences(testRelationshipResult.records, input.registry, input.resolvedSymbol.symbolInstanceId);
246
307
  const suppressedLowConfidenceRecords = neighbors.suppressedLowConfidenceRecords || [];
247
308
  const resolveNodeSymbol = (symbolInstanceId) => (symbolInstanceId === input.resolvedSymbol.symbolInstanceId
248
309
  ? input.resolvedSymbol
@@ -408,6 +469,7 @@ export class RelationshipBackedCallGraph {
408
469
  nodeCount: combinedNodes.length,
409
470
  edgeCount: combinedEdges.length,
410
471
  },
472
+ ...(testReferences.length > 0 ? { testReferences } : {}),
411
473
  ...(hints ? { hints } : {}),
412
474
  };
413
475
  }
@@ -350,7 +350,7 @@ export async function runExactRegistryFastPath(input, host) {
350
350
  partialIndexSearchWarnings: input.partialIndexSearchWarnings,
351
351
  dirtyFilesNotFreshened: input.dirtyFilesNotFreshened,
352
352
  changedFilesBoostSkippedForLargeChangeSet: input.changedFilesBoostSkippedForLargeChangeSet,
353
- buildNoiseMitigationHint: (files) => host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, files, input.scope),
353
+ buildNoiseMitigationHint: (files) => host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, files, input.scope, input.parsedOperators),
354
354
  buildGeneratedArtifactsVerificationHint: (results) => host.buildGeneratedArtifactsVerificationHint(input.effectiveRoot, results),
355
355
  });
356
356
  if (!envelope) {
@@ -4,7 +4,7 @@ import type { SearchCandidateSurvivalDebug, SearchDebugMode, SearchFreshnessSumm
4
4
  import type { SearchQuerySupport } from "./search-query-support.js";
5
5
  import type { SearchQueryPlan, SearchResultLike } from "./search-lexical-scoring.js";
6
6
  import type { ParsedSearchOperators } from "./search-query-planning.js";
7
- import type { VectorBackendDiagnostic } from "./backend-diagnostics.js";
7
+ import { type SemanticPassFailureDiagnostic, type VectorBackendDiagnostic } from "./backend-diagnostics.js";
8
8
  import type { EmbeddingProviderDiagnostic } from "./embedding-provider-diagnostics.js";
9
9
  import type { FreshnessDecision } from "./sync.js";
10
10
  import { type RerankBudgetReason } from "./search-rerank-policy.js";
@@ -39,6 +39,7 @@ export type SearchDiagnostics = SearchProviderWorkDiagnostics & {
39
39
  searchPassCount: number;
40
40
  searchPassSuccessCount: number;
41
41
  searchPassFailureCount: number;
42
+ semanticPassFailures?: SemanticPassFailureDiagnostic[];
42
43
  rerankerAttempted: boolean;
43
44
  rerankerUsed: boolean;
44
45
  };
@@ -149,6 +150,7 @@ export type SearchExecutionOutcome = {
149
150
  };
150
151
  providerWork: SearchProviderWorkDiagnostics;
151
152
  candidateSurvival?: SearchCandidateSurvivalDebug;
153
+ semanticPassFailures: SemanticPassFailureDiagnostic[];
152
154
  } | {
153
155
  kind: "vector_backend_unavailable";
154
156
  diagnostic: VectorBackendDiagnostic;
@@ -157,6 +159,7 @@ export type SearchExecutionOutcome = {
157
159
  diagnostic: EmbeddingProviderDiagnostic;
158
160
  } | {
159
161
  kind: "all_semantic_passes_failed";
162
+ semanticPassFailures: SemanticPassFailureDiagnostic[];
160
163
  };
161
164
  export type SearchExecutionHost = {
162
165
  searchQuerySupport: SearchQuerySupport;
@@ -3,6 +3,7 @@ import { appendCoreCandidateTrace, appendSearchCandidatePass, appendSearchCandid
3
3
  import { WARNING_CODES } from "./warnings.js";
4
4
  import { buildSearchPassWarning as buildSearchPassWarningHelper, } from "./search-response-helpers.js";
5
5
  import { classifyPathCategory, resolveAgentFitMultiplier as resolveSearchAgentFitMultiplier, shouldApplyChangedFilesBoost, shouldIncludeCategoryInScope, sortSearchCandidates as sortSearchCandidatesHelper, } from "./search-ranking-policy.js";
6
+ import { buildSemanticPassFailureDiagnostic, } from "./backend-diagnostics.js";
6
7
  import { resolveRerankFamilyKey, selectRerankCandidates, selectRerankInputWithinUtf8Budget, } from "./search-rerank-policy.js";
7
8
  import { resolveNextSearchCandidateLimit, } from './search-policy.js';
8
9
  const SEARCH_EXPANSION_MIN_PRIMARY_SCOPED_CANDIDATES = 5;
@@ -298,6 +299,7 @@ export async function runSearchExecution(input, host, searchDiagnostics) {
298
299
  const dirtyFilesNotFreshened = observedChangedFilesState.available
299
300
  && observedChangedFilesCount > 0
300
301
  && input.freshnessMode !== "synced"
302
+ && input.freshnessMode !== "skipped_source_unchanged"
301
303
  && input.freshnessMode !== "reconciled_ignore_change";
302
304
  const canSupplementLivePathEvidence = observedChangedFilesState.available
303
305
  && observedChangedFilesCount > 0
@@ -305,6 +307,7 @@ export async function runSearchExecution(input, host, searchDiagnostics) {
305
307
  let boostedCandidates = 0;
306
308
  let attemptsUsed = 0;
307
309
  const searchWarningsSet = new Set();
310
+ const semanticPassFailures = [];
308
311
  const suppressedDirtyPaths = new Set();
309
312
  const representedDirtyPaths = new Set();
310
313
  const passesUsed = new Set();
@@ -438,16 +441,23 @@ export async function runSearchExecution(input, host, searchDiagnostics) {
438
441
  passesUsed.add(passDescriptor.id);
439
442
  continue;
440
443
  }
441
- if (passResult.status === "rejected") {
442
- embeddingProviderDiagnostic ?? (embeddingProviderDiagnostic = host.classifyEmbeddingProviderError(passResult.reason));
443
- if (embeddingProviderDiagnostic === null && vectorBackendDiagnostic === null) {
444
- vectorBackendDiagnostic = host.classifyVectorBackendError(passResult.reason);
445
- }
446
- }
444
+ const passEmbeddingDiagnostic = host.classifyEmbeddingProviderError(passResult.reason);
445
+ const passVectorDiagnostic = passEmbeddingDiagnostic
446
+ ? null
447
+ : host.classifyVectorBackendError(passResult.reason);
448
+ embeddingProviderDiagnostic ?? (embeddingProviderDiagnostic = passEmbeddingDiagnostic);
449
+ vectorBackendDiagnostic ?? (vectorBackendDiagnostic = passVectorDiagnostic);
450
+ semanticPassFailures.push(buildSemanticPassFailureDiagnostic({
451
+ passId: passDescriptor.id,
452
+ error: passResult.reason,
453
+ embeddingDiagnostic: passEmbeddingDiagnostic,
454
+ vectorDiagnostic: passVectorDiagnostic,
455
+ }));
447
456
  searchWarningsSet.add(buildSearchPassWarningHelper(passDescriptor.id));
448
457
  }
449
458
  searchDiagnostics.searchPassSuccessCount += successfulPasses.length;
450
459
  searchDiagnostics.searchPassFailureCount += passDescriptors.length - successfulPasses.length;
460
+ searchDiagnostics.semanticPassFailures = semanticPassFailures.map((failure) => ({ ...failure }));
451
461
  if (successfulPasses.length === 0) {
452
462
  if (embeddingProviderDiagnostic) {
453
463
  return {
@@ -461,7 +471,10 @@ export async function runSearchExecution(input, host, searchDiagnostics) {
461
471
  diagnostic: vectorBackendDiagnostic,
462
472
  };
463
473
  }
464
- return { kind: "all_semantic_passes_failed" };
474
+ return {
475
+ kind: "all_semantic_passes_failed",
476
+ semanticPassFailures,
477
+ };
465
478
  }
466
479
  const byChunkKey = new Map();
467
480
  const attemptFilterSummary = buildEmptyFilterSummary();
@@ -876,6 +889,7 @@ export async function runSearchExecution(input, host, searchDiagnostics) {
876
889
  semanticExpansionAttempted: searchDiagnostics.semanticExpansionAttempted,
877
890
  semanticExpansionReason: searchDiagnostics.semanticExpansionReason,
878
891
  },
892
+ semanticPassFailures,
879
893
  ...(candidateSurvival ? { candidateSurvival } : {}),
880
894
  };
881
895
  }
@@ -28,6 +28,7 @@ function buildReadinessWarnings(host, state) {
28
28
  }
29
29
  function freshnessDecisionPreservesAuthority(decision) {
30
30
  return decision.mode === 'skipped_recent'
31
+ || decision.mode === 'skipped_source_unchanged'
31
32
  || decision.mode === 'skipped_source_checkpoint_unavailable';
32
33
  }
33
34
  function buildBlockedReadinessPayload(state, searchContext, host) {
@@ -57,18 +57,17 @@ function unquoteOperatorValue(value) {
57
57
  return trimmed;
58
58
  }
59
59
  function deriveOperatorOnlySemanticQuery(operators) {
60
- if (operators.must.length !== 1) {
61
- return null;
60
+ const mustTerms = operators.must.map((value) => value.trim()).filter(Boolean);
61
+ if (mustTerms.length > 0) {
62
+ return mustTerms.join(" ");
62
63
  }
63
- const mustValue = operators.must[0].trim();
64
- if (/\s/.test(mustValue)) {
65
- return null;
64
+ const pathTerms = operators.path.map((value) => value.trim()).filter(Boolean);
65
+ if (pathTerms.length > 0) {
66
+ return pathTerms.join(" ");
66
67
  }
67
- const symbolInstanceIdLike = /^syminst_[a-f0-9]{32}$/i.test(mustValue);
68
- const identifierLike = /^[A-Za-z_$][A-Za-z0-9_$]*(?:(?:[.#:/-]|::)[A-Za-z_$][A-Za-z0-9_$]*)*$/.test(mustValue);
69
- const strongIdentifierSignal = /[A-Z_]/.test(mustValue) || /(?:\.|::|#|\/)/.test(mustValue);
70
- if (symbolInstanceIdLike || (identifierLike && strongIdentifierSignal)) {
71
- return mustValue;
68
+ const languageTerms = operators.lang.map((value) => value.trim()).filter(Boolean);
69
+ if (languageTerms.length > 0) {
70
+ return languageTerms.join(" ");
72
71
  }
73
72
  return null;
74
73
  }
@@ -151,7 +150,7 @@ export function parseSearchOperators(query) {
151
150
  const semanticParts = [semanticFromPrefix, semanticSuffix].filter((part) => part.length > 0);
152
151
  operators.semanticQuery = semanticParts.length > 0
153
152
  ? semanticParts.join("\n")
154
- : (deriveOperatorOnlySemanticQuery(operators) || trimmedQuery);
153
+ : (deriveOperatorOnlySemanticQuery(operators) || "");
155
154
  return operators;
156
155
  }
157
156
  function tokenizeLexicalTerms(tokens) {
@@ -99,7 +99,7 @@ export declare class SearchQuerySupport {
99
99
  private loadRootGitignoreMatcher;
100
100
  private patternMatchesAnyPath;
101
101
  private filterNoiseHintPatternsByRootGitignore;
102
- buildNoiseMitigationHint(codebaseRoot: string, filesInOrder: string[], scope: SearchScope): SearchNoiseMitigationHint | undefined;
102
+ buildNoiseMitigationHint(codebaseRoot: string, filesInOrder: string[], scope: SearchScope, parsedOperators: Pick<ParsedSearchOperators, "path">): SearchNoiseMitigationHint | undefined;
103
103
  parseSearchOperators(query: string): ParsedSearchOperators;
104
104
  buildSearchQueryPlan(semanticQuery: string, parsedOperators?: ParsedSearchOperators): SearchQueryPlan;
105
105
  hasTokenBoundaryMatch(field: string, term: string): boolean;
@@ -912,8 +912,8 @@ export class SearchQuerySupport {
912
912
  coveredByRootGitignore,
913
913
  };
914
914
  }
915
- buildNoiseMitigationHint(codebaseRoot, filesInOrder, scope) {
916
- if (scope === 'docs') {
915
+ buildNoiseMitigationHint(codebaseRoot, filesInOrder, scope, parsedOperators) {
916
+ if (scope === 'docs' || scope === 'runtime' || parsedOperators.path.length > 0) {
917
917
  return undefined;
918
918
  }
919
919
  if (!Array.isArray(filesInOrder) || filesInOrder.length === 0) {
@@ -1,6 +1,6 @@
1
1
  import type { SearchGroupBy, SearchScope } from "./search-constants.js";
2
2
  import type { FreshnessDecision } from "./sync.js";
3
- import type { SearchChunkResult, SearchDebugHint, SearchFreshnessDebugHint, SearchFreshnessSummary, SearchGroupResult, SearchDisclosureSummary, SearchRankingDebugHint, SearchGroupedResultV2, SearchResponseEnvelope } from "./search-types.js";
3
+ import type { SearchChunkResult, SearchDebugHint, SearchFreshnessDebugHint, SearchFreshnessSummary, SearchGroupResult, SearchDisclosureSummary, SearchRankingDebugHint, SearchPassFailureDebugHint, SearchGroupedResultV2, SearchResponseEnvelope } from "./search-types.js";
4
4
  import type { CompletionProbeDebugHint } from "./tracked-root-readiness.js";
5
5
  type SearchResponseCommonInput = {
6
6
  codebaseRoot: string;
@@ -13,7 +13,7 @@ type SearchResponseCommonInput = {
13
13
  freshnessSummary: SearchFreshnessSummary;
14
14
  warnings: string[];
15
15
  debugSummary?: NonNullable<NonNullable<SearchResponseEnvelope["hints"]>["debugSummary"]>;
16
- debugSearch?: SearchDebugHint | SearchRankingDebugHint | SearchFreshnessDebugHint;
16
+ debugSearch?: SearchDebugHint | SearchRankingDebugHint | SearchFreshnessDebugHint | SearchPassFailureDebugHint;
17
17
  proofDebugHint?: CompletionProbeDebugHint;
18
18
  noiseMitigationHint?: unknown;
19
19
  generatedArtifactsHint?: unknown;
@@ -5,7 +5,7 @@ import { buildSearchDebugSummary, buildSearchGroupRecommendedAction, SEARCH_GROU
5
5
  import { appendGroupedCandidateStage, appendSearchCandidateRemoval, appendSearchCandidateStage, } from "./search-candidate-survival.js";
6
6
  import { projectGroupedDisclosure } from "./search-disclosure.js";
7
7
  export async function finalizeSearchResults(input, host) {
8
- let { scored, operatorSummary, filterSummary, trackedLexicalDebug, candidateLimit, diagnosticCandidateLimit, attemptsUsed, searchWarnings, passesUsed, backendScoreKinds, exactMatchPinningApplied, boostedCandidates, changedFilesState, debugChangedFilesState, changedFilesCount, changedFilesBoostSkippedForLargeChangeSet, rankingProvenance, rerankerAttempted, rerankerApplied, skippedByExactPin, rerankerFailurePhase, rerankerCandidatesIn, rerankerCandidatesReranked, rerankerFamilyCount, rerankerSupplementalCandidates, rerankerCandidatePoolCount, rerankerCandidateBudget, rerankerBudgetReason, rerankerByteBudgetOmittedCandidates, semanticExpansion, providerWork, candidateSurvival, } = input.execution;
8
+ let { scored, operatorSummary, filterSummary, trackedLexicalDebug, candidateLimit, diagnosticCandidateLimit, attemptsUsed, searchWarnings, passesUsed, backendScoreKinds, exactMatchPinningApplied, boostedCandidates, changedFilesState, debugChangedFilesState, changedFilesCount, changedFilesBoostSkippedForLargeChangeSet, rankingProvenance, rerankerAttempted, rerankerApplied, skippedByExactPin, rerankerFailurePhase, rerankerCandidatesIn, rerankerCandidatesReranked, rerankerFamilyCount, rerankerSupplementalCandidates, rerankerCandidatePoolCount, rerankerCandidateBudget, rerankerBudgetReason, rerankerByteBudgetOmittedCandidates, semanticExpansion, providerWork, candidateSurvival, semanticPassFailures, } = input.execution;
9
9
  let freshnessSummary = input.freshnessSummary;
10
10
  let finalizedSearchWarnings = Array.from(new Set([
11
11
  ...searchWarnings,
@@ -47,6 +47,9 @@ export async function finalizeSearchResults(input, host) {
47
47
  candidatesWithLexicalEvidence: providerWork.candidatesWithLexicalEvidence,
48
48
  candidatesWithCurrentSourceEvidence: providerWork.candidatesWithCurrentSourceEvidence,
49
49
  },
50
+ ...(semanticPassFailures.length > 0 ? {
51
+ semanticPassFailures: semanticPassFailures.map((failure) => ({ ...failure })),
52
+ } : {}),
50
53
  semanticExpansion,
51
54
  rankingProvenance,
52
55
  ...(trackedLexicalDebug ? { trackedLexical: trackedLexicalDebug } : {}),
@@ -200,7 +203,7 @@ export async function finalizeSearchResults(input, host) {
200
203
  debugMode: input.debugMode,
201
204
  now: host.now,
202
205
  });
203
- const noiseMitigationHint = host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, rawResults.map((result) => result.file), input.scope);
206
+ const noiseMitigationHint = host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, rawResults.map((result) => result.file), input.scope, input.parsedOperators);
204
207
  const generatedArtifactsHint = host.buildGeneratedArtifactsVerificationHint(input.effectiveRoot, rawResults.map((result) => ({
205
208
  file: result.file,
206
209
  span: result.span,
@@ -347,7 +350,7 @@ export async function finalizeSearchResults(input, host) {
347
350
  includeSummary: input.disclosureLimit < input.limit
348
351
  || completeDisclosureOrder.length > input.limit,
349
352
  buildEnvelope: (results, disclosure) => {
350
- const noiseMitigationHint = host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, results.map((result) => result.target.file), input.scope);
353
+ const noiseMitigationHint = host.searchQuerySupport.buildNoiseMitigationHint(input.effectiveRoot, results.map((result) => result.target.file), input.scope, input.parsedOperators);
351
354
  const generatedArtifactsHint = host.buildGeneratedArtifactsVerificationHint(input.effectiveRoot, results.map((result) => ({
352
355
  file: result.target.file,
353
356
  span: result.target.span,
@@ -4,6 +4,7 @@ import { SearchGroupBy, SearchNoiseCategory, SearchRankingMode, SearchResultMode
4
4
  import { FingerprintSource, IndexFingerprint } from "../config.js";
5
5
  import type { SearchRouteContract } from "./search-lexical-scoring.js";
6
6
  import type { RerankBudgetReason } from "./search-rerank-policy.js";
7
+ import type { SemanticPassFailureDiagnostic } from "./backend-diagnostics.js";
7
8
  export type StalenessBucket = "fresh" | "aging" | "stale" | "unknown";
8
9
  export declare const SEARCH_RESPONSE_FORMAT_VERSION: 2;
9
10
  export interface SearchSpan {
@@ -304,6 +305,7 @@ export interface SearchDebugHint {
304
305
  rrfK: number;
305
306
  };
306
307
  providerWork: SearchProviderWorkDebugHint;
308
+ semanticPassFailures?: SemanticPassFailureDiagnostic[];
307
309
  candidateSurvival?: SearchCandidateSurvivalDebug;
308
310
  semanticExpansion?: {
309
311
  attempted: boolean;
@@ -463,12 +465,13 @@ export interface SearchDebugHint {
463
465
  };
464
466
  };
465
467
  }
466
- export type SearchRankingDebugHint = Pick<SearchDebugHint, "route" | "queryIntent" | "retrieval" | "mcpFusion" | "providerWork" | "semanticExpansion" | "rankingProvenance" | "trackedLexical" | "exactRegistry" | "passesUsed" | "candidateLimit" | "mustRetry" | "operatorSummary" | "filterSummary" | "diversitySummary" | "changedFilesBoost" | "rerank">;
468
+ export type SearchRankingDebugHint = Pick<SearchDebugHint, "route" | "queryIntent" | "retrieval" | "mcpFusion" | "providerWork" | "semanticPassFailures" | "semanticExpansion" | "rankingProvenance" | "trackedLexical" | "exactRegistry" | "passesUsed" | "candidateLimit" | "mustRetry" | "operatorSummary" | "filterSummary" | "diversitySummary" | "changedFilesBoost" | "rerank">;
467
469
  export type SearchFreshnessDebugHint = Pick<SearchDebugHint, "phaseTimingsMs" | "readiness" | "changedCode">;
470
+ export type SearchPassFailureDebugHint = Pick<SearchDebugHint, "semanticPassFailures">;
468
471
  export interface SearchResponseHints extends Record<string, unknown> {
469
472
  version?: 1;
470
473
  noiseMitigation?: SearchNoiseMitigationHint;
471
- debugSearch?: SearchDebugHint | SearchRankingDebugHint | SearchFreshnessDebugHint;
474
+ debugSearch?: SearchDebugHint | SearchRankingDebugHint | SearchFreshnessDebugHint | SearchPassFailureDebugHint;
472
475
  debugSummary?: {
473
476
  retrieval: string;
474
477
  freshness: FreshnessDecision["mode"] | "skipped_requires_reindex" | "skipped_indexing" | "unknown";
@@ -106,6 +106,12 @@ export declare class SnapshotManager {
106
106
  getCodebaseStatus(codebasePath: string): CodebaseInfo['status'] | 'not_found';
107
107
  getCodebaseInfo(codebasePath: string): CodebaseInfo | undefined;
108
108
  getLatestOperation(codebasePath: string): IndexOperationReceipt | undefined;
109
+ /**
110
+ * Read the latest operation directly from the durable shared snapshot.
111
+ * Cross-process waiters use this instead of trusting their startup cache.
112
+ */
113
+ observeDurableLatestOperation(codebasePath: string): IndexOperationReceipt | undefined;
114
+ operationMatchesRuntimeFingerprint(receipt: IndexOperationReceipt): boolean;
109
115
  setLatestOperation(codebasePath: string, receipt: IndexOperationReceipt): void;
110
116
  startOperation(lease: RootMutationLease): IndexOperationReceipt;
111
117
  transitionOperation(lease: RootMutationLease, phase: IndexOperationPhase): IndexOperationReceipt;
@@ -1175,6 +1175,17 @@ export class SnapshotManager {
1175
1175
  const receipt = this.latestOperations.get(codebasePath);
1176
1176
  return receipt ? structuredClone(receipt) : undefined;
1177
1177
  }
1178
+ /**
1179
+ * Read the latest operation directly from the durable shared snapshot.
1180
+ * Cross-process waiters use this instead of trusting their startup cache.
1181
+ */
1182
+ observeDurableLatestOperation(codebasePath) {
1183
+ const receipt = this.readOperationMapFromDisk().get(codebasePath);
1184
+ return receipt ? structuredClone(receipt) : undefined;
1185
+ }
1186
+ operationMatchesRuntimeFingerprint(receipt) {
1187
+ return fingerprintsEqual(receipt.runtimeFingerprint, this.runtimeFingerprint);
1188
+ }
1178
1189
  setLatestOperation(codebasePath, receipt) {
1179
1190
  if (!this.isValidOperationReceipt(receipt, codebasePath)) {
1180
1191
  throw new Error(`Invalid operation receipt for '${codebasePath}'.`);
@@ -8,8 +8,10 @@ interface SyncManagerOptions {
8
8
  now?: () => number;
9
9
  onSyncCompleted?: (codebasePath: string, stats: SyncStats, assertMutationCurrent: () => void) => Promise<void> | void;
10
10
  mutationLeaseCoordinator?: MutationLeaseCoordinator;
11
+ crossProcessJoinTimeoutMs?: number;
12
+ crossProcessJoinPollMs?: number;
11
13
  }
12
- export type FreshnessDecisionMode = 'synced' | 'skipped_recent' | 'coalesced' | 'skipped_indexing' | 'skipped_requires_reindex' | 'skipped_source_checkpoint_unavailable' | 'skipped_mutation_in_progress' | 'skipped_missing_path' | 'reconciled_ignore_change' | 'ignore_reload_failed';
14
+ export type FreshnessDecisionMode = 'synced' | 'skipped_recent' | 'skipped_source_unchanged' | 'coalesced' | 'skipped_indexing' | 'skipped_requires_reindex' | 'skipped_source_checkpoint_unavailable' | 'skipped_mutation_in_progress' | 'skipped_missing_path' | 'reconciled_ignore_change' | 'ignore_reload_failed';
13
15
  export interface FreshnessDecision {
14
16
  mode: FreshnessDecisionMode;
15
17
  checkedAt: string;
@@ -83,6 +85,7 @@ interface EnsureFreshnessOptions {
83
85
  skipIgnoreControlCheck?: boolean;
84
86
  mutationLease?: RootMutationLease;
85
87
  preparedVectorReceipt?: ProvenVectorGenerationReceipt;
88
+ exactSourceComparisonPaths?: readonly string[];
86
89
  }
87
90
  export declare class SyncOperationError extends Error {
88
91
  readonly operation: IndexOperationReceipt | undefined;
@@ -115,6 +118,8 @@ export declare class SyncManager {
115
118
  private readonly now;
116
119
  private readonly onSyncCompleted?;
117
120
  private readonly mutationLeaseCoordinator?;
121
+ private readonly crossProcessJoinTimeoutMs;
122
+ private readonly crossProcessJoinPollMs;
118
123
  constructor(context: Context, snapshotManager: SnapshotManager, options?: SyncManagerOptions);
119
124
  private bumpFreshnessEpoch;
120
125
  getPreparedReadObservation(codebasePath: string): PreparedReadObservationResult;
@@ -132,6 +137,7 @@ export declare class SyncManager {
132
137
  private runIgnoreReconcile;
133
138
  private reconcileIgnoreRulesChange;
134
139
  private syncCodebase;
140
+ private joinCrossProcessSync;
135
141
  handleSyncIndex(): Promise<void>;
136
142
  startBackgroundSync(): void;
137
143
  stopBackgroundSync(): void;
package/dist/core/sync.js CHANGED
@@ -8,6 +8,8 @@ import { BACKGROUND_FRESHNESS_THRESHOLD_MS, BACKGROUND_SYNC_INITIAL_DELAY_MS, BA
8
8
  import { formatMutationLeaseBlockedMessage, } from "./mutation-lease.js";
9
9
  // v1 policy: only root-level control files trigger index-policy reconciliation.
10
10
  const IGNORE_RULE_CONTROL_FILES = new Set(['.satoriignore', '.gitignore', 'satori.toml']);
11
+ const DEFAULT_CROSS_PROCESS_JOIN_TIMEOUT_MS = 15000;
12
+ const DEFAULT_CROSS_PROCESS_JOIN_POLL_MS = 25;
11
13
  function errorMessage(error, fallback = "unknown_error") {
12
14
  if (error instanceof Error && error.message) {
13
15
  return error.message;
@@ -60,6 +62,8 @@ export class SyncManager {
60
62
  this.now = options.now || (() => Date.now());
61
63
  this.onSyncCompleted = options.onSyncCompleted;
62
64
  this.mutationLeaseCoordinator = options.mutationLeaseCoordinator;
65
+ this.crossProcessJoinTimeoutMs = Math.max(1, options.crossProcessJoinTimeoutMs ?? DEFAULT_CROSS_PROCESS_JOIN_TIMEOUT_MS);
66
+ this.crossProcessJoinPollMs = Math.max(1, options.crossProcessJoinPollMs ?? DEFAULT_CROSS_PROCESS_JOIN_POLL_MS);
63
67
  }
64
68
  bumpFreshnessEpoch(codebasePath) {
65
69
  this.freshnessEpochs.set(codebasePath, (this.freshnessEpochs.get(codebasePath) ?? 0) + 1);
@@ -329,6 +333,20 @@ export class SyncManager {
329
333
  }
330
334
  }
331
335
  }
336
+ const exactSourceComparisonPaths = options.exactSourceComparisonPaths;
337
+ if (exactSourceComparisonPaths && exactSourceComparisonPaths.length > 0) {
338
+ const compareSourcePaths = this.context.compareSourcePathsToFreshnessCheckpoint;
339
+ if (typeof compareSourcePaths === 'function') {
340
+ const comparison = await compareSourcePaths.call(this.context, codebasePath, exactSourceComparisonPaths, options.preparedVectorReceipt);
341
+ if (comparison.status === 'matches') {
342
+ return {
343
+ mode: 'skipped_source_unchanged',
344
+ checkedAt,
345
+ thresholdMs,
346
+ };
347
+ }
348
+ }
349
+ }
332
350
  // 2. Throttling: Skip if recently synced
333
351
  const lastSync = this.lastSyncTimes.get(codebasePath) || 0;
334
352
  const timeSince = checkedAtMs - lastSync;
@@ -347,7 +365,9 @@ export class SyncManager {
347
365
  this.bumpFreshnessEpoch(codebasePath);
348
366
  const syncPromise = (async () => {
349
367
  try {
350
- return await this.syncCodebase(codebasePath, options.mutationLease, currentIgnoreControlSignature);
368
+ return await this.syncCodebase(codebasePath, options.mutationLease, currentIgnoreControlSignature, {
369
+ exactSourceComparisonPaths: options.exactSourceComparisonPaths,
370
+ });
351
371
  }
352
372
  catch (e) {
353
373
  // Log and rethrow to allow callers to handle/see failure
@@ -386,6 +406,7 @@ export class SyncManager {
386
406
  } : undefined,
387
407
  activeMutation: outcome.activeMutation,
388
408
  operation: outcome.operation,
409
+ errorMessage: outcome.errorMessage,
389
410
  };
390
411
  }
391
412
  async runIgnoreReconcile(codebasePath, coalescedEdits = 1, nextIgnoreControlSignature, existingLease) {
@@ -576,7 +597,7 @@ export class SyncManager {
576
597
  };
577
598
  }
578
599
  }
579
- async syncCodebase(codebasePath, existingLease, currentIgnoreControlSignature) {
600
+ async syncCodebase(codebasePath, existingLease, currentIgnoreControlSignature, joinRequest = {}) {
580
601
  if (this.snapshotManager.getCodebaseStatus(codebasePath) === 'indexing') {
581
602
  console.log(`[SYNC] ⏭️ Skipping sync for '${codebasePath}' because indexing is active.`);
582
603
  return { mode: 'skipped_indexing' };
@@ -595,6 +616,9 @@ export class SyncManager {
595
616
  else {
596
617
  const acquired = this.mutationLeaseCoordinator.acquire(codebasePath, 'sync');
597
618
  if (!acquired.acquired) {
619
+ if (acquired.activeLease.action === 'sync') {
620
+ return this.joinCrossProcessSync(codebasePath, acquired.activeLease, joinRequest);
621
+ }
598
622
  return { mode: 'skipped_mutation_in_progress', activeMutation: acquired.activeLease };
599
623
  }
600
624
  lease = acquired.lease;
@@ -778,6 +802,164 @@ export class SyncManager {
778
802
  }
779
803
  }
780
804
  }
805
+ async joinCrossProcessSync(codebasePath, activeLease, request) {
806
+ const observeOperation = this.snapshotManager.observeDurableLatestOperation;
807
+ const matchesRuntime = this.snapshotManager.operationMatchesRuntimeFingerprint;
808
+ if (!this.mutationLeaseCoordinator
809
+ || typeof observeOperation !== 'function'
810
+ || typeof matchesRuntime !== 'function') {
811
+ return {
812
+ mode: 'skipped_mutation_in_progress',
813
+ activeMutation: activeLease,
814
+ };
815
+ }
816
+ const deadline = Date.now() + this.crossProcessJoinTimeoutMs;
817
+ let lastOperation;
818
+ while (Date.now() <= deadline) {
819
+ const operation = observeOperation.call(this.snapshotManager, codebasePath);
820
+ if (operation
821
+ && operation.id === activeLease.operationId
822
+ && operation.generation === activeLease.generation
823
+ && operation.action === 'sync'
824
+ && operation.canonicalRoot === activeLease.canonicalRoot) {
825
+ lastOperation = operation;
826
+ if (!matchesRuntime.call(this.snapshotManager, operation)) {
827
+ return {
828
+ mode: 'coalesced',
829
+ activeMutation: activeLease,
830
+ operation,
831
+ errorMessage: 'The in-flight sync uses an incompatible runtime fingerprint.',
832
+ };
833
+ }
834
+ if (operation.phase === 'failed' || operation.phase === 'blocked') {
835
+ return {
836
+ mode: 'coalesced',
837
+ activeMutation: activeLease,
838
+ operation,
839
+ errorMessage: `The joined sync ended in terminal phase '${operation.phase}'.`,
840
+ };
841
+ }
842
+ if (operation.phase === 'completed') {
843
+ let checkpoint = await this.inspectSourceFreshnessCheckpoint(codebasePath);
844
+ if (!checkpoint || checkpoint.status !== 'valid') {
845
+ return {
846
+ mode: 'coalesced',
847
+ activeMutation: activeLease,
848
+ operation,
849
+ errorMessage: 'The joined sync completed without a proven active source checkpoint.',
850
+ };
851
+ }
852
+ const registeredObservation = this.context.getRegisteredSourceFreshnessCheckpointObservation?.(codebasePath);
853
+ if (registeredObservation !== checkpoint.observationToken
854
+ && typeof this.context.recreateSynchronizerForCodebase === 'function') {
855
+ try {
856
+ await this.context.recreateSynchronizerForCodebase(codebasePath, undefined, undefined, { requireAuthorityCheckpoint: true });
857
+ }
858
+ catch {
859
+ return {
860
+ mode: 'coalesced',
861
+ activeMutation: activeLease,
862
+ operation,
863
+ errorMessage: 'The joined sync completed, but this runtime could not bind its source checkpoint to the active publication.',
864
+ };
865
+ }
866
+ checkpoint = await this.inspectSourceFreshnessCheckpoint(codebasePath);
867
+ if (!checkpoint || checkpoint.status !== 'valid') {
868
+ return {
869
+ mode: 'coalesced',
870
+ activeMutation: activeLease,
871
+ operation,
872
+ errorMessage: 'The joined sync source checkpoint changed while this runtime was binding it.',
873
+ };
874
+ }
875
+ if (this.context.getRegisteredSourceFreshnessCheckpointObservation?.(codebasePath)
876
+ !== checkpoint.observationToken) {
877
+ return {
878
+ mode: 'coalesced',
879
+ activeMutation: activeLease,
880
+ operation,
881
+ errorMessage: 'The joined sync source checkpoint did not bind to the active publication.',
882
+ };
883
+ }
884
+ }
885
+ const paths = request.exactSourceComparisonPaths;
886
+ if (paths && paths.length > 0) {
887
+ const compareSourcePaths = this.context.compareSourcePathsToFreshnessCheckpoint;
888
+ if (typeof compareSourcePaths !== 'function') {
889
+ return {
890
+ mode: 'coalesced',
891
+ activeMutation: activeLease,
892
+ operation,
893
+ errorMessage: 'The joined sync cannot prove the requested source observation.',
894
+ };
895
+ }
896
+ const comparison = await compareSourcePaths.call(this.context, codebasePath, paths);
897
+ if (comparison.status !== 'matches') {
898
+ return {
899
+ mode: 'coalesced',
900
+ activeMutation: activeLease,
901
+ operation,
902
+ errorMessage: `The joined sync did not prove the requested source observation (${comparison.status}).`,
903
+ };
904
+ }
905
+ }
906
+ const finalOperation = observeOperation.call(this.snapshotManager, codebasePath);
907
+ if (!finalOperation
908
+ || finalOperation.id !== operation.id
909
+ || finalOperation.generation !== operation.generation
910
+ || finalOperation.phase !== 'completed') {
911
+ return {
912
+ mode: 'coalesced',
913
+ activeMutation: activeLease,
914
+ ...(finalOperation ? { operation: finalOperation } : { operation }),
915
+ errorMessage: 'The durable sync authority changed before the joined result could be accepted.',
916
+ };
917
+ }
918
+ this.sourceCheckpointStatuses.set(codebasePath, 'valid');
919
+ this.sourceCheckpointObservations.set(codebasePath, checkpoint.observationToken);
920
+ this.lastSyncTimes.set(codebasePath, this.now());
921
+ return {
922
+ mode: 'coalesced',
923
+ activeMutation: activeLease,
924
+ operation,
925
+ };
926
+ }
927
+ }
928
+ else if (operation && operation.generation >= activeLease.generation) {
929
+ return {
930
+ mode: 'coalesced',
931
+ activeMutation: activeLease,
932
+ operation,
933
+ errorMessage: 'The durable sync operation no longer matches the active mutation lease.',
934
+ };
935
+ }
936
+ const currentLease = this.mutationLeaseCoordinator.getActiveLease(codebasePath);
937
+ if (!currentLease
938
+ || currentLease.operationId !== activeLease.operationId
939
+ || currentLease.generation !== activeLease.generation) {
940
+ const terminal = observeOperation.call(this.snapshotManager, codebasePath);
941
+ if (terminal?.id === activeLease.operationId
942
+ && terminal.generation === activeLease.generation
943
+ && terminal.phase === 'completed') {
944
+ lastOperation = terminal;
945
+ continue;
946
+ }
947
+ return {
948
+ mode: 'coalesced',
949
+ activeMutation: activeLease,
950
+ ...(terminal ? { operation: terminal } : lastOperation ? { operation: lastOperation } : {}),
951
+ errorMessage: 'The in-flight sync lost its durable owner before proving completion.',
952
+ };
953
+ }
954
+ await new Promise((resolve) => setTimeout(resolve, this.crossProcessJoinPollMs));
955
+ }
956
+ return {
957
+ mode: 'coalesced',
958
+ activeMutation: activeLease,
959
+ ...(lastOperation ? { operation: lastOperation } : {}),
960
+ errorMessage: 'Timed out waiting for the in-flight sync to prove completion.',
961
+ };
962
+ }
781
963
  async handleSyncIndex() {
782
964
  const indexedCodebases = this.snapshotManager.getIndexedCodebases();
783
965
  if (indexedCodebases.length === 0)
@@ -250,6 +250,7 @@ export class ToolResponseBuilders {
250
250
  return null;
251
251
  case "synced":
252
252
  case "skipped_recent":
253
+ case "skipped_source_unchanged":
253
254
  case "skipped_source_checkpoint_unavailable":
254
255
  case "reconciled_ignore_change":
255
256
  case "skipped_mutation_in_progress":
@@ -202,6 +202,13 @@ const buildSearchSchema = (ctx) => z.object({
202
202
  message: "disclosureLimit cannot exceed limit.",
203
203
  });
204
204
  }
205
+ if (parseSearchOperators(value.query).semanticQuery.trim().length === 0) {
206
+ refinementContext.addIssue({
207
+ code: z.ZodIssueCode.custom,
208
+ path: ["query"],
209
+ message: "Operator-only search requires semantic text or a positive must:, path:, or lang: value.",
210
+ });
211
+ }
205
212
  });
206
213
  export const searchCodebaseTool = {
207
214
  name: "search_codebase",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zokizuan/satori-mcp",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Repo-aware MCP tools for AI coding agents: intent search, symbol navigation, advisory call graphs, exact reads, and freshness-aware recovery.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "jsonc-parser": "^3.3.1",
16
16
  "zod": "^3.25.55",
17
17
  "zod-to-json-schema": "^3.25.1",
18
- "@zokizuan/satori-core": "3.0.0"
18
+ "@zokizuan/satori-core": "3.1.0"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "^20.0.0",