Compare commits

...

9 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 852de5a722 test(core): make bigram_overlay_coherence_test cross-platform + run on Windows
docs / docs (push) Has been cancelled
seed_realistic_repo baked '/' separators into relative paths, then fed
those paths to base.join() and picker.remove_file_by_path(). On Windows
the picker indexes native 'src\\core\\...', so the lookup missed every
file and the whole test harness broke whenever Windows was enabled in
CI.

Switch path construction to PathBuf::push via a native_rel_path helper
so separators match the picker's indexed form on every platform.

Also extend the rust.yml 'Test' matrix to windows-latest so this suite
now runs there too, with fail-fast: false so a Windows-only regression
doesn't hide failures on the other platforms.
2026-05-03 16:56:34 -07:00
Dmitriy Kovalenko 9a28be677c fix(mcp): accept 'pattern' as an alias for 'query' on grep/find_files
multi_grep uses 'patterns' while grep/find_files use 'query'; LLMs
routinely confuse the two and trip on 'missing field `query`' (#311).
Add a serde alias so the wrong-but-intuitive name also works without
changing the published JSON schema.
2026-05-03 15:56:10 -07:00
Dmitriy Kovalenko 5cc374bd76 fix(core): demote 'git status path not in index' to debug
Sparse checkouts routinely produce paths in the git index that are not
materialized on disk. Those paths aren't in the file picker's index
either, so update_git_statuses() hit the 'couldn't update' branch on
every scan and spammed ERROR logs, which perceivably slowed Neovim
picker usage (#404).

Downgrade to debug — this is expected, not exceptional.
2026-05-03 15:53:36 -07:00
Dmitriy Kovalenko cf1d6bab1c fix(pi-fff): serialize ensureFinder via in-flight promise cache
Parallel ffgrep/fffind calls in the same turn all entered ensureFinder()
simultaneously. Each attempted FileFinder.create() on the shared base
path, which acquires exclusive native locks on the frecency and history
DBs — a second concurrent creation could deadlock at the native layer
with no timeout, hanging the whole turn.

Gate ensureFinder() on an in-flight promise so only one FileFinder.create()
runs per base path at a time; concurrent callers await the same promise.

Fixes #403.
2026-05-03 15:51:40 -07:00
Dmitriy Kovalenko 7217657c84 fix(mcp): treat maxResults=0 consistently across tools
Previously 'maxResults: 0' was asymmetric: grep/multi_grep truncated the
output list to zero items while find_files interpreted 0 as 'unlimited'
and returned the full (potentially huge) result set.

Centralize the parsing through normalize_max_results() and treat 0 (plus
negatives and non-finite values) as 'use the default'. Also round
fractional values instead of truncating (0.4 → 1, 10.7 → 11).

Fixes #400 (issue 3).
2026-05-03 15:24:08 -07:00
Dmitriy Kovalenko d40d4c010d fix(mcp): stop surfacing regex-fallback warning alongside matches
When a grep pattern like 'foo(' triggers regex compilation and fails,
the core engine already falls back to literal matching and returns the
correct results. Prepending '! regex failed: ...' on top of those
results only confuses LLM callers (issue #425) — they see the error
before the matches and conclude the call failed.

Drop the warning from the formatter output entirely; the core still
populates the field for debugging and other bindings.
2026-05-03 15:18:43 -07:00
Dmitriy Kovalenko 7d138b1567 test(pi-fff): regression tests for absolute path normalization
Lock in the contract from #423: absolute in-workspace paths (file,
directory, glob) must be rewritten to their repo-relative form before
being emitted as constraints.
2026-05-03 15:15:27 -07:00
Dmitriy Kovalenko 4d159086ae test(pi-fff): regression tests for path='.' and bare directory path
Guard against regression of #432 where path='.' or path='app' (without
trailing slash) returned zero results. Current code normalizes both
correctly but tests lock in the contract.
2026-05-03 15:14:55 -07:00
Dmitriy Kovalenko 57d7b791b9 test(score): regression tests for constraint-only git-status queries
Add tests covering 'git:modified' and 'status:modified' alias to prevent
regression of #308 where constraint-only queries reused raw_query as fuzzy
text and returned zero matches.
2026-05-03 15:12:05 -07:00
8 changed files with 296 additions and 50 deletions
+6 -4
View File
@@ -17,14 +17,15 @@ jobs:
name: Test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest, macos-latest, windows-latest]
# Guard against deadlocks in the shared-picker / watcher teardown
# path: a stuck test would otherwise consume a full 6h CI slot.
timeout-minutes: 10
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
# Zig is required to compile zlob
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
@@ -36,10 +37,11 @@ jobs:
with:
cache: true
cache-on-failure: true
cache-key: "v1-rust"
cache-key: "v1-rust-${{ matrix.os }}"
components: rustfmt, clippy
- name: Run tests
shell: bash
run: cargo test --features zlob --workspace --exclude fff-nvim
stress-test:
+4 -1
View File
@@ -1286,7 +1286,10 @@ impl FilePicker {
dir.update_frecency_if_larger(score);
}
} else {
error!(?path, "Couldn't update the git status for path");
// Expected on sparse checkouts: git reports a status for
// a path that isn't materialized on disk and therefore
// isn't in the file index. Don't spam the log (#404).
debug!(?path, "Git status for path not in index, skipping");
}
Ok(())
})?;
+105
View File
@@ -1462,3 +1462,108 @@ mod typo_resistance_tests {
);
}
}
#[cfg(test)]
mod constraint_only_query_tests {
use super::*;
use crate::types::PaginationArgs;
use fff_query_parser::QueryParser;
#[test]
fn constraint_only_git_status_query_returns_filtered_files() {
let paths = ["modified.rs", "clean.rs"];
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let items: Vec<FileItem> = paths
.iter()
.map(|p| {
let fname = p.rfind(std::path::is_separator).map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(fname, 0, 0, None, false)
})
.collect();
let (store, strings) =
crate::simd_path::build_chunked_path_store_from_strings(&path_strings, &items);
let arena = store.as_arena_ptr();
let mut files: Vec<FileItem> = items;
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
files[0].git_status = Some(git2::Status::WT_MODIFIED);
files[1].git_status = Some(git2::Status::CURRENT);
std::mem::forget(store);
let parser = QueryParser::default();
let parsed = parser.parse("git:modified");
let ctx = ScoringContext {
query: &parsed,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 50,
},
};
let (items, _scores, total_matched) =
fuzzy_match_and_score_files(&files, &ctx, files.len(), arena, arena);
assert_eq!(
total_matched, 1,
"git:modified constraint-only query should match exactly 1 modified file"
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].relative_path(arena), "modified.rs");
}
#[test]
fn constraint_only_status_modified_alias_returns_filtered_files() {
let paths = ["a.rs", "b.rs"];
let path_strings: Vec<String> = paths.iter().map(|p| p.to_string()).collect();
let items: Vec<FileItem> = paths
.iter()
.map(|p| {
let fname = p.rfind(std::path::is_separator).map(|i| i + 1).unwrap_or(0) as u16;
FileItem::new_raw(fname, 0, 0, None, false)
})
.collect();
let (store, strings) =
crate::simd_path::build_chunked_path_store_from_strings(&path_strings, &items);
let arena = store.as_arena_ptr();
let mut files: Vec<FileItem> = items;
for (i, file) in files.iter_mut().enumerate() {
file.set_path(strings[i].clone());
}
files[0].git_status = Some(git2::Status::WT_MODIFIED);
files[1].git_status = Some(git2::Status::CURRENT);
std::mem::forget(store);
let parser = QueryParser::default();
let parsed = parser.parse("status:modified");
let ctx = ScoringContext {
query: &parsed,
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 50,
},
};
let (items, _scores, total_matched) =
fuzzy_match_and_score_files(&files, &ctx, files.len(), arena, arena);
assert_eq!(total_matched, 1);
assert_eq!(items[0].relative_path(arena), "a.rs");
}
}
@@ -1429,6 +1429,17 @@ fn write_file_with_token(dir: &Path, name: &str, token: &str) {
fs::write(dir.join(name), content).unwrap();
}
/// Join path components with the platform-native separator so the produced
/// relative path matches what the file picker's indexer stores after walking
/// the filesystem (Windows: `\`, everything else: `/`).
fn native_rel_path(components: &[&str]) -> String {
let mut pb = PathBuf::new();
for c in components {
pb.push(c);
}
pb.to_string_lossy().into_owned()
}
/// Create a realistic 200-file repository with subdirectories and diverse
/// content. Files are split into 4 thematic groups (50 each) so that
/// group-specific bigrams appear in ~25% of files, keeping the bigram
@@ -1438,19 +1449,26 @@ fn write_file_with_token(dir: &Path, name: &str, token: &str) {
/// child dirs) so that the directory sort order is consistent with the
/// binary search in `find_file_index`.
///
/// Returns `Vec<(relative_path, token)>` for all 200 files.
/// Returns `Vec<(relative_path, token)>` for all 200 files. The relative
/// path uses the platform-native separator (matching the picker index).
fn seed_realistic_repo(dir: &Path) -> Vec<(String, String)> {
let subdirs = [
"src/core",
"src/models",
"src/net",
"src/utils",
"tests/integration",
"tests/unit",
"lib/helpers",
"lib/internal",
// Subdir components. Joined with `PathBuf` so Windows ends up with
// `src\core` instead of `src/core` — otherwise the picker (which walks
// the filesystem and stores native separators) and the test (which
// stored `/` literals) would disagree and `remove_file_by_path` /
// `find_file_index` lookups would all miss.
let subdirs: [&[&str]; 8] = [
&["src", "core"],
&["src", "models"],
&["src", "net"],
&["src", "utils"],
&["tests", "integration"],
&["tests", "unit"],
&["lib", "helpers"],
&["lib", "internal"],
];
for subdir in &subdirs {
let subdir_paths: Vec<String> = subdirs.iter().copied().map(native_rel_path).collect();
for subdir in &subdir_paths {
fs::create_dir_all(dir.join(subdir)).unwrap();
}
@@ -1542,8 +1560,9 @@ fn seed_realistic_repo(dir: &Path) -> Vec<(String, String)> {
for (group_idx, (domain, words)) in groups.iter().enumerate() {
for file_in_group in 0..50 {
let idx = group_idx * 50 + file_in_group;
let subdir = subdirs[idx % subdirs.len()];
let relative_path = format!("{subdir}/repo_{idx:04}.rs");
let subdir = &subdir_paths[idx % subdir_paths.len()];
let file_name = format!("repo_{idx:04}.rs");
let relative_path = native_rel_path(&[subdir, &file_name]);
let token = format!("REPO_TOKEN_{idx:04}");
let w1 = words[file_in_group % words.len()];
-6
View File
@@ -154,7 +154,6 @@ pub struct GrepFormatter<'a> {
pub files: &'a [&'a FileItem],
pub total_matched: usize,
pub next_file_offset: usize,
pub regex_fallback_error: Option<&'a str>,
pub output_mode: OutputMode,
pub max_results: usize,
pub show_context: bool,
@@ -169,7 +168,6 @@ impl GrepFormatter<'_> {
files,
total_matched,
next_file_offset,
regex_fallback_error,
output_mode,
max_results,
show_context,
@@ -216,10 +214,6 @@ impl GrepFormatter<'_> {
2500
};
if let Some(err) = regex_fallback_error {
lines.push(format!("! regex failed: {}, using literal match", err));
}
// File overview: collect first match per file
let file_preview = collect_file_preview(items, files, picker);
let mut content_def_file = String::new();
+83 -7
View File
@@ -19,6 +19,20 @@ use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
/// Normalize the caller-supplied `maxResults`.
///
/// `None`, `Some(0)`, and non-positive / non-finite values fall back to
/// `default`. Issue #400 reported that grep returned 0 items for
/// `maxResults: 0` while `find_files` returned the entire dataset; treating
/// 0 as "use the default" makes both tools behave consistently.
fn normalize_max_results(raw: Option<f64>, default: usize) -> usize {
match raw {
None => default,
Some(v) if v <= 0.0 || !v.is_finite() => default,
Some(v) => (v.round() as usize).max(1),
}
}
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
@@ -71,6 +85,9 @@ fn make_grep_options(
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct FindFilesParams {
/// Fuzzy search query. Supports path prefixes and glob constraints.
// `pattern` alias for consistency with grep's alias and the common
// file-search parameter name (#311).
#[serde(alias = "pattern")]
pub query: String,
/// Max results (default 20).
#[serde(rename = "maxResults")]
@@ -84,6 +101,10 @@ pub struct FindFilesParams {
pub struct GrepParams {
/// Search text or regex query with optional constraint prefixes.
/// Matches within single lines only — use ONE specific term, not multiple words.
// `pattern` alias: LLMs that have seen multi_grep (which uses `patterns`)
// routinely call grep with `pattern`; accept it instead of erroring out
// with an unhelpful "missing field `query`" (#311).
#[serde(alias = "pattern")]
pub query: String,
/// Max matching lines (default 20).
#[serde(rename = "maxResults")]
@@ -273,7 +294,6 @@ impl FffServer {
files: &retry_result.files,
total_matched: retry_result.matches.len(),
next_file_offset: retry_result.next_file_offset,
regex_fallback_error: retry_result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
@@ -363,7 +383,6 @@ impl FffServer {
files: &result.files,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: result.regex_fallback_error.as_deref(),
output_mode,
max_results,
show_context: ctx_lines > 0,
@@ -391,7 +410,7 @@ impl FffServer {
&self,
Parameters(params): Parameters<FindFilesParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0).round() as usize; // safe
let max_results = normalize_max_results(params.max_results, 20);
let query = &params.query;
let page_offset = params
@@ -503,7 +522,7 @@ impl FffServer {
&self,
Parameters(params): Parameters<GrepParams>,
) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0) as usize;
let max_results = normalize_max_results(params.max_results, 20);
let output_mode = OutputMode::new(params.output_mode.as_deref());
let parsed = QueryParser::new(AiGrepConfig).parse(&params.query);
@@ -545,7 +564,7 @@ impl FffServer {
impl FffServer {
fn multi_grep_inner(&self, params: MultiGrepParams) -> Result<CallToolResult, ErrorData> {
let max_results = params.max_results.unwrap_or(20.0).round() as usize;
let max_results = normalize_max_results(params.max_results, 20);
let context = params.context.map(|v| v.round() as usize);
let output_mode = OutputMode::new(params.output_mode.as_deref());
@@ -604,7 +623,6 @@ impl FffServer {
files: &fb_file_refs,
total_matched: fb_result.matches.len(),
next_file_offset: fb_result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: false,
@@ -636,7 +654,6 @@ impl FffServer {
files: &file_refs,
total_matched: result.matches.len(),
next_file_offset: result.next_file_offset,
regex_fallback_error: None,
output_mode,
max_results,
show_context: ctx_lines > 0,
@@ -664,3 +681,62 @@ impl ServerHandler for FffServer {
.with_instructions(instructions)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_max_results_none_uses_default() {
assert_eq!(normalize_max_results(None, 20), 20);
}
#[test]
fn normalize_max_results_zero_uses_default() {
// Issue #400: `maxResults: 0` must not return zero items for grep
// while `find_files` returns the full set. Both tools now map 0 to
// the default limit.
assert_eq!(normalize_max_results(Some(0.0), 20), 20);
}
#[test]
fn normalize_max_results_negative_uses_default() {
assert_eq!(normalize_max_results(Some(-5.0), 20), 20);
}
#[test]
fn normalize_max_results_non_finite_uses_default() {
assert_eq!(normalize_max_results(Some(f64::NAN), 20), 20);
assert_eq!(normalize_max_results(Some(f64::INFINITY), 20), 20);
}
#[test]
fn normalize_max_results_rounds_and_clamps() {
assert_eq!(normalize_max_results(Some(0.4), 20), 1);
assert_eq!(normalize_max_results(Some(10.0), 20), 10);
assert_eq!(normalize_max_results(Some(10.7), 20), 11);
}
#[test]
fn grep_params_accepts_pattern_alias() {
// Issue #311: LLMs flip between `query` and `pattern`; accept both.
let via_query: GrepParams =
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
assert_eq!(via_query.query, "foo");
let via_pattern: GrepParams =
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
assert_eq!(via_pattern.query, "foo");
}
#[test]
fn find_files_params_accepts_pattern_alias() {
let via_query: FindFilesParams =
serde_json::from_str(r#"{"query":"foo"}"#).expect("query field");
assert_eq!(via_query.query, "foo");
let via_pattern: FindFilesParams =
serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias");
assert_eq!(via_pattern.query, "foo");
}
}
+33 -19
View File
@@ -292,6 +292,11 @@ function createFffMentionProvider(
export default function fffExtension(pi: ExtensionAPI) {
let finder: FileFinder | null = null;
let finderCwd: string | null = null;
// Concurrent ensureFinder() callers share the same in-flight promise so
// FileFinder.create() (which takes native DB locks) runs at most once per
// base path at a time — otherwise parallel tool calls would race and
// deadlock at the native layer (issue #403).
let finderPromise: Promise<FileFinder> | null = null;
let activeCwd = process.cwd();
// Mode resolution: flag > env > default
@@ -324,28 +329,37 @@ export default function fffExtension(pi: ExtensionAPI) {
return currentMode !== "tools-only";
}
async function ensureFinder(cwd: string): Promise<FileFinder> {
if (finder && !finder.isDestroyed && finderCwd === cwd) return finder;
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
finderCwd = null;
}
function ensureFinder(cwd: string): Promise<FileFinder> {
if (finder && !finder.isDestroyed && finderCwd === cwd)
return Promise.resolve(finder);
if (finderPromise) return finderPromise;
const result = FileFinder.create({
basePath: cwd,
frecencyDbPath,
historyDbPath,
aiMode: true,
finderPromise = (async () => {
if (finder && !finder.isDestroyed) {
finder.destroy();
finder = null;
finderCwd = null;
}
const result = FileFinder.create({
basePath: cwd,
frecencyDbPath,
historyDbPath,
aiMode: true,
});
if (!result.ok)
throw new Error(`Failed to create FFF file finder: ${result.error}`);
finder = result.value;
finderCwd = cwd;
await finder.waitForScan(15000);
return finder;
})().finally(() => {
finderPromise = null;
});
if (!result.ok)
throw new Error(`Failed to create FFF file finder: ${result.error}`);
finder = result.value;
finderCwd = cwd;
await finder.waitForScan(15000);
return finder;
return finderPromise;
}
function destroyFinder() {
+33
View File
@@ -29,4 +29,37 @@ describe("path constraint normalization", () => {
buildQuery("/tmp/workspace/.agents/**", "*", "/tmp/workspace/test/**", cwd),
).toBe(".agents/ !test/ *");
});
test("treats path='.' as workspace root (no constraint)", () => {
expect(normalizePathConstraint(".", cwd)).toBeNull();
expect(normalizePathConstraint("./", cwd)).toBeNull();
expect(buildQuery(".", "needle", undefined, cwd)).toBe("needle");
});
test("treats absolute workspace root as no constraint", () => {
expect(normalizePathConstraint(cwd, cwd)).toBeNull();
expect(buildQuery(cwd, "needle", undefined, cwd)).toBe("needle");
});
test("bare directory path without trailing slash becomes PathSegment", () => {
expect(normalizePathConstraint("app", cwd)).toBe("app/");
expect(normalizePathConstraint("src/nested", cwd)).toBe("src/nested/");
expect(buildQuery("app", "needle", undefined, cwd)).toBe("app/ needle");
});
test("converts absolute in-workspace file path to repo-relative", () => {
expect(normalizePathConstraint("/tmp/workspace/src/main.rs", cwd)).toBe("src/main.rs");
expect(buildQuery("/tmp/workspace/src/main.rs", "needle", undefined, cwd)).toBe(
"src/main.rs needle",
);
});
test("converts absolute in-workspace directory (without trailing slash) to repo-relative", () => {
expect(normalizePathConstraint("/tmp/workspace/src", cwd)).toBe("src/");
expect(buildQuery("/tmp/workspace/src", "needle", undefined, cwd)).toBe("src/ needle");
});
test("converts absolute in-workspace glob path to repo-relative glob", () => {
expect(normalizePathConstraint("/tmp/workspace/src/**/*.ts", cwd)).toBe("src/**/*.ts");
});
});