Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ finalized in place with a date — no renaming/migration step needed.
- **Group fan-out hits resolve in their origin store, with the alias-prefixed path.** Chunk ids are per-repo counters, so across a group every repo answers for id 0 and any two non-empty repos collide. The fan-out helpers deduplicated by (alias, chunk_id) but flattened to bare results, so every downstream resolver probed all stores and kept the first get_chunk(id) hit — the alphabetically-first repo stole the hit, the true definition died in the KINDS filter, unrelated content rode the stolen score, and group paths never picked up their alias prefix. MultiReadOutcome now carries SourcedResult{alias, result} from the helpers themselves: find definition/usages, literal search (BM25 and scan paths), group semantic in all three modes ((alias, chunk_id) interned to unique synthetic ids for the bare-u32 RRF fusion), graph imports/dependents/similar, explore outline dedup and get_chunk auto-routing all resolve the chunk only in its origin store and prefix the path from that store's alias; merge_exact_into_fts gains a (alias, chunk_id)-keyed sibling for the group lexical path.
- **Mixed-model groups warn that scores are not comparable.** A group whose repos embed with different models answers with cosine distances from different vector spaces, which the merged ranking presents as one scale — an agent reading the top hit's score as "strongest match overall" is comparing coordinates from unrelated spaces. The group path now collects the distinct models that actually answered and appends a warning naming every one of them; RRF fusion in auto/hybrid partially hides the problem by fusing ranks only, so semantic_mode's raw cosine scores are where it bites hardest.
- **Literal search relaxes to OR when the exact AND query matches nothing.** Business queries arrive as sentences, and a chunk holding most of their terms is a better answer than silence. When the conjunctive pass returns nothing and the query carries at least two significant (non-stopword) terms, the search retries as a disjunction and keeps candidates covering 60% of those terms (minimum two); coverage counts terms in the stored searchable_text (signature + docstring + content — the same fields the relaxed pass queries and rescues by), with a bare-content fallback for legacy blobs, so a candidate rescued BY its signature is no longer dropped by a gate that only read its content. The response flags relaxed_fallback and explains the mode in a note. Single-term queries never relax, so an empty answer for an unknown identifier stays an honest refusal.
- **All responses emit 1-based line numbers.** Chunks carry 0-indexed start/end lines by design and every consumer emitted them raw — a symbol defined on the first line of a file showed start_line 0. The conversion happens exactly once at each final emit boundary (semantic funnel, literal match lines, find, explore outline, get_chunk, similar chunks, imports, CLI renderer); intermediate vectordb results, store metadata, chunk-internal context slicing and SCIP-based positions stay untouched, and the Chunk doc comment pins the convention so the offset cannot be applied twice.
- **All responses emit 1-based line numbers.** A symbol defined on the first line of a file used to show start_line 0. The chunkers emit 1-based editor lines — the same convention the SCIP position index and find_impact position queries use — and every response surface passes them through unchanged (semantic funnel, literal match lines, find, explore outline, get_chunk, similar chunks, imports, CLI renderer); intermediate vectordb results, store metadata, chunk-internal context slicing and SCIP-based positions stay untouched. The Chunk doc comment pins the convention, and an end-to-end test ties the real chunker to the get_chunk response so the two ends cannot drift apart again (an earlier draft of this batch added a +1 at every emit boundary on top of 0-based chunkers; once the chunkers moved to 1-based that double-converted every line, which the merge reconciled to emit-passthrough).
- **get_chunk reads surrounding context from the owning repo's root.** Chunk paths are stored project-relative, and the group-scoped answer rewrites them with the alias prefix before the context read — which then joined the prefixed path onto the serve process's own project root, a location that never exists. Every group-mode get_chunk with context_lines therefore answered "source file not readable, returning indexed content only", always. The context read now resolves the pre-prefix stored path against the owning alias's root from the routing context (`alias_roots`), keeping the historical join only for stdio single-repo mode where it was already correct.
- **Optional min_score threshold for semantic search, with an explicit refusal.** Nearest-neighbour search always answers something, so junk queries got confidently scored garbage and negative probes looked like weak hits. Hits below the opt-in threshold are dropped in the single funnel every local mode shares, and an answer emptied by the filter carries a note naming the threshold instead of reading as "no match in the index"; scores are mode-specific (cosine similarity for semantic_mode="semantic", RRF points for auto/hybrid/lexical) and the parameter docs spell out both scales. Federated group/project queries now forward the threshold to every leg and surface each leg's refusal note in `warnings` (the local leg hardcoded None and the peer body omitted the field, so both silently ignored it); literal mode has no calibratable scale and refuses the parameter by name instead of silently dropping it. Default behaviour is unchanged.
- **Debounced config persist no longer clobbers racing registrations.** The persist worker cloned its config snapshot and wrote repos.json without the config lock, so a POST /repos landing in that window existed only in memory while the stale snapshot hit disk — every reader-side reload_if_changed then trusted the fresh mtime as an external edit and swapped the disk copy in, silently dropping the registration (alias 404s in /info while its build ended with "repo removed or cancelled mid-index"). persist_config now adopts its own write's mtime so an internal persist never masquerades as an external edit, and the persist pass re-verifies memory against the snapshot after each write, rewriting until the file converges.
Expand Down
7 changes: 4 additions & 3 deletions src/chunker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ pub struct Chunk {
/// The actual content of the chunk
pub content: String,

/// Starting line number (0-indexed; responses convert to 1-based editor
/// lines at the emit boundary)
/// Starting line number (1-based editor line, as emitted by every
/// chunker; responses pass it through unchanged)
pub start_line: usize,

/// Ending line number (0-indexed; converted to 1-based on emit)
/// Ending line number (1-based editor line, as emitted by every
/// chunker; responses pass it through unchanged)
pub end_line: usize,

/// Type of chunk
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ impl CodesearchService {
chunk_id: c.id,
kind: c.kind,
signature: c.signature,
start_line: c.start_line + 1,
end_line: c.end_line + 1,
start_line: c.start_line,
end_line: c.end_line,
});
}
}
Expand All @@ -133,8 +133,8 @@ impl CodesearchService {
chunk_id: c.id,
kind: c.kind,
signature: c.signature,
start_line: c.start_line + 1,
end_line: c.end_line + 1,
start_line: c.start_line,
end_line: c.end_line,
})
.collect();
out.sort_by_key(|i| i.start_line);
Expand Down
8 changes: 4 additions & 4 deletions src/mcp/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ impl CodesearchService {
items.push(ReferenceItem {
chunk_id: fts_hit.result.chunk_id,
path: ctx.prefix_sourced_path(&fts_hit.alias, &chunk.path),
line: chunk.start_line + 1,
line: chunk.start_line,
kind: chunk.kind,
signature: chunk.signature,
score: fts_hit.result.score,
Expand Down Expand Up @@ -264,7 +264,7 @@ impl CodesearchService {
Some(ReferenceItem {
chunk_id,
path: chunk.path,
line: chunk.start_line + 1,
line: chunk.start_line,
kind: chunk.kind,
signature: chunk.signature,
score,
Expand Down Expand Up @@ -420,7 +420,7 @@ impl CodesearchService {
items.push(ReferenceItem {
chunk_id: fts_hit.result.chunk_id,
path: ctx.prefix_sourced_path(&fts_hit.alias, &chunk.path),
line: chunk.start_line + 1,
line: chunk.start_line,
kind: chunk.kind,
signature: chunk.signature,
score: fts_hit.result.score,
Expand Down Expand Up @@ -467,7 +467,7 @@ impl CodesearchService {
Some(ReferenceItem {
chunk_id,
path: chunk.path,
line: chunk.start_line + 1,
line: chunk.start_line,
kind: chunk.kind,
signature: chunk.signature,
score,
Expand Down
13 changes: 6 additions & 7 deletions src/mcp/get_chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,11 @@ impl CodesearchService {
Ok(src) => {
let lines: Vec<&str> = src.lines().collect();
if !lines.is_empty() {
// Chunk lines are 0-based (the 1-based conversion
// happens once at the emit boundary below); the
// slice ends at the row before the chunk's first
// line, which is start_line itself.
let before_end = chunk.start_line.min(lines.len());
// Chunk lines are 1-based; slices need the 0-based
// row before the chunk's first line.
let before_end = chunk.start_line.saturating_sub(1);
let before_start = before_end.saturating_sub(context_lines);
let before_end = before_end.min(lines.len());
if before_start < before_end {
context_before = Some(lines[before_start..before_end].join("\n"));
}
Expand All @@ -375,8 +374,8 @@ impl CodesearchService {
let response = GetChunkResponse {
chunk_id: request.chunk_id,
path: chunk.path,
start_line: chunk.start_line + 1,
end_line: chunk.end_line + 1,
start_line: chunk.start_line,
end_line: chunk.end_line,
kind: chunk.kind,
signature: chunk.signature,
content: chunk.content,
Expand Down
12 changes: 6 additions & 6 deletions src/mcp/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl CodesearchService {

out.push(DependentItem {
path: ctx.prefix_sourced_path(&f.alias, &chunk.path),
line: chunk.start_line + 1,
line: chunk.start_line,
import_statement,
});
}
Expand Down Expand Up @@ -509,7 +509,7 @@ impl CodesearchService {

out.push(DependentItem {
path: chunk.path,
line: chunk.start_line + 1,
line: chunk.start_line,
import_statement,
});

Expand Down Expand Up @@ -669,8 +669,8 @@ impl CodesearchService {
all_results.push(SearchResultItem {
chunk_id: Some(r.id),
path: ctx.prefix_sourced_path(alias, &r.path),
start_line: r.start_line + 1,
end_line: r.end_line + 1,
start_line: r.start_line,
end_line: r.end_line,
kind: r.kind,
score: r.score,
signature: r.signature,
Expand Down Expand Up @@ -726,8 +726,8 @@ impl CodesearchService {
.map(|r| SearchResultItem {
chunk_id: Some(r.id),
path: r.path,
start_line: r.start_line + 1,
end_line: r.end_line + 1,
start_line: r.start_line,
end_line: r.end_line,
kind: r.kind,
score: r.score,
signature: r.signature,
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1322,8 +1322,8 @@ impl CodesearchService {
path: r.path,
// Chunks store 0-indexed lines; every response emits 1-based
// editor lines, so the conversion happens once, here.
start_line: r.start_line + 1,
end_line: r.end_line + 1,
start_line: r.start_line,
end_line: r.end_line,
kind: r.kind,
score: r.score,
signature: r.signature,
Expand Down
8 changes: 4 additions & 4 deletions src/search/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,8 +935,8 @@ pub async fn search(query: &str, path: Option<PathBuf>, options: SearchOptions)
.iter()
.map(|r| JsonResult {
path: r.path.clone(),
start_line: r.start_line + 1,
end_line: r.end_line + 1,
start_line: r.start_line,
end_line: r.end_line,
kind: r.kind.clone(),
content: if compact {
None
Expand Down Expand Up @@ -1276,8 +1276,8 @@ fn print_result(
// Show location and kind
let location = format!(
" Lines {}-{} • {}",
result.start_line + 1,
result.end_line + 1,
result.start_line,
result.end_line,
sanitize_for_terminal(&result.kind)
);
println!("{}", location.dimmed());
Expand Down
Loading
Loading