Skip to content

Commit a5847a6

Browse files
authored
Merge branch 'main' into codex/issue-801-libgit2-floor
2 parents 87ec7bc + 185451a commit a5847a6

2 files changed

Lines changed: 219 additions & 22 deletions

File tree

src/mcp/mcp.c

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4435,9 +4435,19 @@ static void classify_all_grep_hits(grep_match_t *gm, int gm_count, cbm_store_t *
44354435
}
44364436
}
44374437

4438-
/* Write indexed file list for scoped grep. Returns true if scoped. */
4438+
/* Write indexed file list for scoped grep. Returns true if scoped.
4439+
* When a path_filter is provided, apply it here — before grep — so large
4440+
* indexed projects do not scan files only for collect_grep_matches to discard
4441+
* them later. The predicate is IDENTICAL to the post-grep filter: the same
4442+
* compiled regex run against the same root-relative path (separators
4443+
* normalized on Windows first), so prefiltering can only skip files whose
4444+
* hits would be dropped anyway — results-preserving by construction.
4445+
* *out_written receives the number of records written (0 = the filter
4446+
* excluded every indexed file). */
44394447
static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, const char *root_path,
4440-
const char *filelist) {
4448+
const char *filelist, bool has_path_filter,
4449+
cbm_regex_t *path_regex, int *out_written) {
4450+
*out_written = 0;
44414451
cbm_store_t *pre_store = resolve_store(srv, project);
44424452
if (!pre_store) {
44434453
return false;
@@ -4450,8 +4460,17 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
44504460
}
44514461
FILE *fl = fopen(filelist, "wb");
44524462
bool ok = false;
4463+
int written = 0;
44534464
if (fl) {
44544465
for (int fi = 0; fi < indexed_count; fi++) {
4466+
if (has_path_filter && path_regex) {
4467+
#ifdef _WIN32
4468+
cbm_normalize_path_sep(indexed_files[fi]);
4469+
#endif
4470+
if (cbm_regexec(path_regex, indexed_files[fi], 0, NULL, 0) != CBM_REG_OK) {
4471+
continue;
4472+
}
4473+
}
44554474
/* Write "<root>/<file>" piece-by-piece (no fixed-size buffer, so an
44564475
* arbitrarily long absolute path cannot overflow). Forward slash join
44574476
* so xargs doesn't treat Windows backslashes as escapes; binary mode
@@ -4468,6 +4487,7 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
44684487
#else
44694488
(void)fputc('\0', fl);
44704489
#endif
4490+
written++;
44714491
}
44724492
(void)fclose(fl);
44734493
ok = true;
@@ -4476,6 +4496,7 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
44764496
free(indexed_files[fi]);
44774497
}
44784498
free(indexed_files);
4499+
*out_written = written;
44794500
return ok;
44804501
}
44814502

@@ -4699,33 +4720,47 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
46994720
char filelist[CBM_SZ_256];
47004721
snprintf(filelist, sizeof(filelist), "%s.files", tmpfile);
47014722
bool scoped = false;
4723+
int scoped_written = 0;
47024724

4703-
scoped = write_scoped_filelist(srv, project, root_path, filelist);
4725+
scoped = write_scoped_filelist(srv, project, root_path, filelist, has_path_filter,
4726+
has_path_filter ? &path_regex : NULL, &scoped_written);
47044727

4705-
char cmd[CBM_SZ_4K];
4706-
build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile, filelist, root_path);
4728+
/* Collect grep matches into array */
4729+
int gm_count = 0;
4730+
grep_match_t *gm = NULL;
4731+
if (scoped && scoped_written == 0) {
4732+
/* The path_filter excluded every indexed file — nothing to scan.
4733+
* Skip the grep subprocess: xargs on an empty filelist is
4734+
* platform-dependent (GNU execs grep once with no operands, BSD
4735+
* skips), and the post-grep filter would drop every hit anyway. */
4736+
gm = malloc(sizeof(grep_match_t)); /* empty set; freed below */
4737+
cbm_unlink(tmpfile);
4738+
cbm_unlink(filelist);
4739+
} else {
4740+
char cmd[CBM_SZ_4K];
4741+
build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile, filelist,
4742+
root_path);
4743+
4744+
FILE *fp = cbm_popen(cmd, "r");
4745+
if (!fp) {
4746+
cbm_unlink(tmpfile);
4747+
if (scoped) {
4748+
cbm_unlink(filelist);
4749+
}
4750+
free(root_path);
4751+
free(pattern);
4752+
free(project);
4753+
free(file_pattern);
4754+
return cbm_mcp_text_result("search failed", true);
4755+
}
47074756

4708-
FILE *fp = cbm_popen(cmd, "r");
4709-
if (!fp) {
4757+
gm = collect_grep_matches(fp, root_path, strlen(root_path), has_path_filter, &path_regex,
4758+
grep_limit, &gm_count);
4759+
cbm_pclose(fp);
47104760
cbm_unlink(tmpfile);
47114761
if (scoped) {
47124762
cbm_unlink(filelist);
47134763
}
4714-
free(root_path);
4715-
free(pattern);
4716-
free(project);
4717-
free(file_pattern);
4718-
return cbm_mcp_text_result("search failed", true);
4719-
}
4720-
4721-
/* Collect grep matches into array */
4722-
int gm_count = 0;
4723-
grep_match_t *gm = collect_grep_matches(fp, root_path, strlen(root_path), has_path_filter,
4724-
&path_regex, grep_limit, &gm_count);
4725-
cbm_pclose(fp);
4726-
cbm_unlink(tmpfile);
4727-
if (scoped) {
4728-
cbm_unlink(filelist);
47294764
}
47304765

47314766
/* ── Phase 2+3: Block expansion + graph ranking ──────────── */

tests/test_mcp.c

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,166 @@ TEST(search_code_scoped_path_with_spaces_issue687) {
14931493
PASS();
14941494
}
14951495

1496+
/* Shared fixture for the path_filter prefilter tests (PR #756 distilled):
1497+
* a project with two indexed files that both contain the search pattern —
1498+
* src/handler.go (inside the filter) and vendor/other.go (outside it). */
1499+
static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path,
1500+
size_t src_sz, char *vendor_path, size_t vendor_sz) {
1501+
snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX");
1502+
if (!cbm_mkdtemp(tmp)) {
1503+
return NULL;
1504+
}
1505+
char dir[640];
1506+
snprintf(dir, sizeof(dir), "%s/src", tmp);
1507+
cbm_mkdir(dir);
1508+
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
1509+
cbm_mkdir(dir);
1510+
1511+
snprintf(src_path, src_sz, "%s/src/handler.go", tmp);
1512+
snprintf(vendor_path, vendor_sz, "%s/vendor/other.go", tmp);
1513+
FILE *fp = fopen(src_path, "w");
1514+
if (!fp) {
1515+
return NULL;
1516+
}
1517+
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
1518+
fclose(fp);
1519+
fp = fopen(vendor_path, "w");
1520+
if (!fp) {
1521+
return NULL;
1522+
}
1523+
fprintf(fp, "package vendored\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
1524+
fclose(fp);
1525+
1526+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
1527+
if (!srv) {
1528+
return NULL;
1529+
}
1530+
cbm_store_t *st = cbm_mcp_server_store(srv);
1531+
const char *proj = "prefilter-search";
1532+
cbm_mcp_server_set_project(srv, proj);
1533+
cbm_store_upsert_project(st, proj, tmp);
1534+
1535+
cbm_node_t n1 = {.project = proj,
1536+
.label = "Function",
1537+
.name = "HandleRequest",
1538+
.qualified_name = "prefilter-search.main.HandleRequest",
1539+
.file_path = "src/handler.go",
1540+
.start_line = 3,
1541+
.end_line = 5};
1542+
cbm_node_t n2 = {.project = proj,
1543+
.label = "Function",
1544+
.name = "HandleRequest",
1545+
.qualified_name = "prefilter-search.vendored.HandleRequest",
1546+
.file_path = "vendor/other.go",
1547+
.start_line = 3,
1548+
.end_line = 5};
1549+
if (cbm_store_upsert_node(st, &n1) <= 0 || cbm_store_upsert_node(st, &n2) <= 0) {
1550+
cbm_mcp_server_free(srv);
1551+
return NULL;
1552+
}
1553+
return srv;
1554+
}
1555+
1556+
static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path) {
1557+
char dir[640];
1558+
unlink(src_path);
1559+
unlink(vendor_path);
1560+
snprintf(dir, sizeof(dir), "%s/src", tmp);
1561+
rmdir(dir);
1562+
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
1563+
rmdir(dir);
1564+
rmdir(tmp);
1565+
}
1566+
1567+
/* PR #756 (distilled): scoped search_code prefilters the indexed filelist by
1568+
* path_filter before grep runs. POSITIVE invariant guard: a path_filter that
1569+
* matches the file containing the hit must still return that hit (guards
1570+
* against over-filtering — the prefilter predicate must stay IDENTICAL to the
1571+
* post-grep filter in collect_grep_matches), and files outside the filter
1572+
* stay excluded. Green on pre-prefilter main too (the post-grep filter alone
1573+
* produced the same results): the change is results-preserving perf-only. */
1574+
TEST(search_code_path_filter_prefilter_keeps_matches) {
1575+
char tmp[512], src_path[768], vendor_path[768];
1576+
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
1577+
vendor_path, sizeof(vendor_path));
1578+
ASSERT_NOT_NULL(srv);
1579+
1580+
char *resp = cbm_mcp_server_handle(
1581+
srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\","
1582+
"\"params\":{\"name\":\"search_code\","
1583+
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
1584+
"\"path_filter\":\"^src/\"}}}");
1585+
ASSERT_NOT_NULL(resp);
1586+
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
1587+
char *inner = extract_text_content(resp);
1588+
ASSERT_NOT_NULL(inner);
1589+
1590+
/* The in-filter hit is returned; the out-of-filter file is not. */
1591+
ASSERT_NOT_NULL(strstr(inner, "src/handler.go"));
1592+
ASSERT_TRUE(strstr(inner, "vendor/other.go") == NULL);
1593+
1594+
/* Exactly the one in-filter grep match survives (same count before and
1595+
* after the prefilter — predicate identity). */
1596+
int grep_matches = -1;
1597+
const char *g = strstr(inner, "\"total_grep_matches\":");
1598+
if (g) {
1599+
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
1600+
}
1601+
ASSERT_EQ(grep_matches, 1);
1602+
1603+
free(inner);
1604+
free(resp);
1605+
cbm_mcp_server_free(srv);
1606+
cleanup_prefilter_dir(tmp, src_path, vendor_path);
1607+
PASS();
1608+
}
1609+
1610+
/* PR #756 (distilled): path_filter matching ZERO indexed files. With the
1611+
* prefilter the scoped filelist has 0 records, and handle_search_code now
1612+
* skips the grep subprocess entirely (xargs on an empty filelist is
1613+
* platform-dependent: GNU execs grep once with no operands, BSD skips) and
1614+
* returns the empty result directly. Must be a clean zero-result response —
1615+
* no error. Green on pre-prefilter main too (there the full filelist is
1616+
* grepped and the post-grep filter drops every hit — an empty filelist is
1617+
* unreachable on main): guards the edge the prefilter introduces. */
1618+
TEST(search_code_path_filter_matches_nothing) {
1619+
char tmp[512], src_path[768], vendor_path[768];
1620+
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
1621+
vendor_path, sizeof(vendor_path));
1622+
ASSERT_NOT_NULL(srv);
1623+
1624+
char *resp = cbm_mcp_server_handle(
1625+
srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\","
1626+
"\"params\":{\"name\":\"search_code\","
1627+
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
1628+
"\"path_filter\":\"^no_such_dir/\"}}}");
1629+
ASSERT_NOT_NULL(resp);
1630+
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
1631+
char *inner = extract_text_content(resp);
1632+
ASSERT_NOT_NULL(inner);
1633+
1634+
int grep_matches = -1;
1635+
const char *g = strstr(inner, "\"total_grep_matches\":");
1636+
if (g) {
1637+
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
1638+
}
1639+
ASSERT_EQ(grep_matches, 0);
1640+
int results = -1;
1641+
const char *r = strstr(inner, "\"total_results\":");
1642+
if (r) {
1643+
sscanf(r, "\"total_results\":%d", &results);
1644+
}
1645+
ASSERT_EQ(results, 0);
1646+
ASSERT_TRUE(strstr(inner, "handler.go") == NULL);
1647+
ASSERT_TRUE(strstr(inner, "other.go") == NULL);
1648+
1649+
free(inner);
1650+
free(resp);
1651+
cbm_mcp_server_free(srv);
1652+
cleanup_prefilter_dir(tmp, src_path, vendor_path);
1653+
PASS();
1654+
}
1655+
14961656
/* issue #283: search_code with regex=true and a syntactically invalid pattern
14971657
* must return an explicit error, not an empty result indistinguishable from a
14981658
* legitimate no-match. */
@@ -3810,6 +3970,8 @@ SUITE(mcp) {
38103970
RUN_TEST(tool_search_code_no_project);
38113971
RUN_TEST(search_code_multi_word);
38123972
RUN_TEST(search_code_scoped_path_with_spaces_issue687);
3973+
RUN_TEST(search_code_path_filter_prefilter_keeps_matches);
3974+
RUN_TEST(search_code_path_filter_matches_nothing);
38133975
RUN_TEST(search_code_invalid_regex_errors_issue283);
38143976
RUN_TEST(search_code_literal_pipe_warns_issue282);
38153977
RUN_TEST(search_code_ampersand_accepted_issue272);

0 commit comments

Comments
 (0)