@@ -9888,19 +9888,77 @@ static int mcp_run_shell_command_cancellable(cbm_mcp_server_t *srv, const char *
98889888 return contained ? 0 : -1 ;
98899889}
98909890
9891- /* Collect BFS seed ids: every symbol DEFINED in a changed file (everything but
9892- * the structural container labels — those have no CALLS edges). These anchor
9893- * the multi-source impact traversal. */
9891+ /* Does `node`'s line range overlap any recorded hunk for `file`? Used to scope
9892+ * seed detection to the actually-changed lines rather than the whole file.
9893+ * Non-static (declared in mcp_internal.h) so tests can exercise the overlap
9894+ * logic directly, matching this file's existing white-box test hooks. */
9895+ bool cbm_detect_node_in_hunks (const cbm_node_t * node , const cbm_changed_hunk_t * hunks ,
9896+ int hunk_count , const char * file ) {
9897+ for (int h = 0 ; h < hunk_count ; h ++ ) {
9898+ if (strcmp (hunks [h ].path , file ) == 0 && node -> start_line <= hunks [h ].end_line &&
9899+ node -> end_line >= hunks [h ].start_line ) {
9900+ return true;
9901+ }
9902+ }
9903+ return false;
9904+ }
9905+
9906+ /* Collect BFS seed ids for one changed file (everything but the structural
9907+ * container labels — those have no CALLS edges). These anchor the
9908+ * multi-source impact traversal.
9909+ *
9910+ * When `hunks` has at least one entry for `file`, only definitions whose line
9911+ * range overlaps a hunk become seeds — a one-line edit inside a single
9912+ * function no longer seeds every other definition in the file. When no hunk
9913+ * is recorded for `file` (a brand-new/untracked file has no comparable
9914+ * "before" state, or the hunk fetch failed/was skipped), every non-container
9915+ * definition in the file is a seed — the previous, whole-file behavior — so
9916+ * this is a precision improvement, not a new failure mode. */
9917+ /* Structural container labels carry no CALLS edges and span the whole file, so
9918+ * they are never seeds. Shared by the seeding loop and the overlap probe. */
9919+ static bool detect_is_seedable_label (const char * lb ) {
9920+ return lb && strcmp (lb , "File" ) != 0 && strcmp (lb , "Folder" ) != 0 &&
9921+ strcmp (lb , "Project" ) != 0 && strcmp (lb , "Module" ) != 0 && strcmp (lb , "Package" ) != 0 &&
9922+ strcmp (lb , "Section" ) != 0 ;
9923+ }
9924+
98949925static void detect_collect_seeds (cbm_store_t * store , const char * project , const char * file ,
9895- int64_t * * seeds , int * n , int * cap ) {
9926+ const cbm_changed_hunk_t * hunks , int hunk_count , int64_t * * seeds ,
9927+ int * n , int * cap ) {
98969928 cbm_node_t * nodes = NULL ;
98979929 int ncount = 0 ;
98989930 cbm_store_find_nodes_by_file (store , project , file , & nodes , & ncount );
9931+ bool scope_to_hunks = false;
9932+ for (int h = 0 ; h < hunk_count ; h ++ ) {
9933+ if (strcmp (hunks [h ].path , file ) == 0 ) {
9934+ scope_to_hunks = true;
9935+ break ;
9936+ }
9937+ }
9938+ /* A file can have hunks yet no SEEDABLE definition overlapping any of them:
9939+ * an import-only edit, a module-level constant, or a change above the first
9940+ * definition all land outside every definition's line range. Scoping would
9941+ * then drop the file from the seed set entirely — strictly worse recall
9942+ * than the whole-file behavior this replaces. Probe for an overlap first
9943+ * and keep whole-file seeding for that file when there is none.
9944+ *
9945+ * The probe must apply the same label filter as the seeding loop below:
9946+ * container nodes span the whole file (a Module node is lines 1..EOF), so
9947+ * counting them would report an overlap for every hunk and defeat the
9948+ * fallback entirely. */
9949+ if (scope_to_hunks ) {
9950+ bool any_overlap = false;
9951+ for (int i = 0 ; i < ncount && !any_overlap ; i ++ ) {
9952+ any_overlap = detect_is_seedable_label (nodes [i ].label ) &&
9953+ cbm_detect_node_in_hunks (& nodes [i ], hunks , hunk_count , file );
9954+ }
9955+ scope_to_hunks = any_overlap ;
9956+ }
98999957 for (int i = 0 ; i < ncount ; i ++ ) {
9900- const char * lb = nodes [i ].label ;
9901- if (lb && strcmp ( lb , "File" ) != 0 && strcmp ( lb , "Folder" ) != 0 &&
9902- strcmp ( lb , "Project" ) != 0 && strcmp ( lb , "Module" ) != 0 && strcmp ( lb , "Package" ) != 0 &&
9903- strcmp ( lb , "Section" ) != 0 ) {
9958+ if ( detect_is_seedable_label ( nodes [i ].label )) {
9959+ if (scope_to_hunks && ! cbm_detect_node_in_hunks ( & nodes [ i ], hunks , hunk_count , file )) {
9960+ continue ;
9961+ }
99049962 if (* n >= * cap ) {
99059963 * cap = * cap ? * cap * 2 : 16 ;
99069964 * seeds = safe_realloc (* seeds , (size_t )* cap * sizeof (int64_t ));
@@ -10187,6 +10245,76 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1018710245 int seed_count = 0 ;
1018810246 int seed_cap = 0 ;
1018910247
10248+ /* Hunk line ranges (unified=0 diff), used to scope seed detection to the
10249+ * actually-changed lines instead of every definition in a changed file
10250+ * (see detect_collect_seeds). Best-effort: any failure here just leaves
10251+ * `hunks` empty and every file falls back to its previous whole-file
10252+ * seeding — this is a precision improvement, not a correctness
10253+ * dependency, so it is never treated as a request-level failure.
10254+ *
10255+ * Coordinate systems: `base...HEAD` hunks carry HEAD-side line numbers,
10256+ * the worktree diff carries worktree-side ones, and node line ranges come
10257+ * from the indexed snapshot. These agree while the index is fresh — the
10258+ * watcher reindexes on HEAD movement and on a dirty tree — but a stale
10259+ * index combined with insertions earlier in the file shifts the node lines
10260+ * relative to the hunks and can mis-scope. The failure is bounded by
10261+ * detect_collect_seeds' zero-overlap fallback: a file whose definitions all
10262+ * miss reverts to whole-file seeding rather than dropping out. */
10263+ cbm_changed_hunk_t * hunks = NULL ;
10264+ int hunk_count = 0 ;
10265+ if (want_symbols ) {
10266+ char hunk_cmd [CBM_SZ_2K ];
10267+ #ifdef _WIN32
10268+ snprintf (hunk_cmd , sizeof (hunk_cmd ),
10269+ "git -C \"%s\" diff --unified=0 \"%s\"...HEAD 2>NUL & "
10270+ "git -C \"%s\" diff --unified=0 2>NUL" ,
10271+ root_path , base_branch , root_path );
10272+ #else
10273+ snprintf (hunk_cmd , sizeof (hunk_cmd ),
10274+ "{ git -C '%s' diff --unified=0 '%s'...HEAD 2>/dev/null; "
10275+ "git -C '%s' diff --unified=0 2>/dev/null; }" ,
10276+ root_path , base_branch , root_path );
10277+ #endif
10278+ char hunk_output_path [CBM_SZ_2K ] = {0 };
10279+ cbm_proc_result_t hunk_result = {0 };
10280+ int hunk_run =
10281+ mcp_run_shell_command_cancellable (srv , hunk_cmd , hunk_output_path , & hunk_result );
10282+ bool hunk_cancelled = hunk_result .cancellation_requested || mcp_request_cancelled (srv );
10283+ FILE * hfp = (!hunk_cancelled && hunk_run == 0 ) ? cbm_fopen (hunk_output_path , "rb" ) : NULL ;
10284+ if (hfp ) {
10285+ (void )fseek (hfp , 0 , SEEK_END );
10286+ long hsz = ftell (hfp );
10287+ if (hsz > 0 ) {
10288+ (void )fseek (hfp , 0 , SEEK_SET );
10289+ char * hbuf = malloc ((size_t )hsz + SKIP_ONE );
10290+ if (hbuf ) {
10291+ size_t hread = fread (hbuf , SKIP_ONE , (size_t )hsz , hfp );
10292+ hbuf [hread ] = '\0' ;
10293+ enum { HUNK_CAP = 4096 };
10294+ hunks = safe_realloc (NULL , (size_t )HUNK_CAP * sizeof (cbm_changed_hunk_t ));
10295+ hunk_count = cbm_parse_hunks (hbuf , hunks , HUNK_CAP );
10296+ /* A filled buffer means the diff was truncated: the hunks
10297+ * past the cap are gone, so files captured only partially
10298+ * would still look scoped and silently under-seed. Drop
10299+ * scoping for the whole request rather than under-report a
10300+ * large refactor — whole-file seeding is the safe side. */
10301+ if (hunk_count >= HUNK_CAP ) {
10302+ cbm_log_info ("detect_changes.hunks" , "action" , "scoping_disabled" , "reason" ,
10303+ "hunk_cap_reached" );
10304+ free (hunks );
10305+ hunks = NULL ;
10306+ hunk_count = 0 ;
10307+ }
10308+ free (hbuf );
10309+ }
10310+ }
10311+ (void )fclose (hfp );
10312+ }
10313+ if (hunk_output_path [0 ]) {
10314+ (void )cbm_unlink (hunk_output_path );
10315+ }
10316+ }
10317+
1019010318 char line [CBM_SZ_1K ];
1019110319 while (fgets (line , sizeof (line ), fp )) {
1019210320 size_t len = strlen (line );
@@ -10229,7 +10357,8 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1022910357 }
1023010358 files [file_count ++ ] = heap_strdup (path_line );
1023110359 if (want_symbols ) {
10232- detect_collect_seeds (store , project , path_line , & seeds , & seed_count , & seed_cap );
10360+ detect_collect_seeds (store , project , path_line , hunks , hunk_count , & seeds , & seed_count ,
10361+ & seed_cap );
1023310362 }
1023410363 }
1023510364 (void )fclose (fp );
@@ -10275,6 +10404,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1027510404 }
1027610405 free (files );
1027710406 free (seeds );
10407+ free (hunks );
1027810408 free (direction );
1027910409 free (root_path );
1028010410 free (project );
@@ -10432,6 +10562,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1043210562 }
1043310563 free (files );
1043410564 free (seeds );
10565+ free (hunks );
1043510566 free (direction );
1043610567 free (root_path );
1043710568 free (project );
0 commit comments