@@ -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