Skip to content

Commit 2789301

Browse files
authored
Merge pull request DeusData#1372 from lishixiang0705/fix/detect-changes-seed-scope-1363
fix(mcp): scope detect_changes seed detection to changed line ranges
2 parents 880b87a + 9235a23 commit 2789301

5 files changed

Lines changed: 357 additions & 18 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
98949925
static 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);

‎src/mcp/mcp_internal.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
#define CBM_MCP_INTERNAL_H
33

44
#include "mcp/mcp.h"
5+
#include "pipeline/pipeline.h" /* cbm_changed_hunk_t */
6+
#include "store/store.h" /* cbm_node_t */
57

68
/* White-box fault injection for deterministic cross-platform quarantine
79
* safety tests. This header is internal and is not part of the MCP API. */
@@ -41,4 +43,10 @@ const char *cbm_mcp_edge_strategy_class(const char *strategy);
4143
bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
4244
int *file_count_out);
4345

46+
/* detect_changes seed scoping (#1363): does `node`'s line range overlap any
47+
* recorded hunk for `file`? Exposed for direct unit testing of the overlap
48+
* logic, independent of the git/subprocess/index plumbing around it. */
49+
bool cbm_detect_node_in_hunks(const cbm_node_t *node, const cbm_changed_hunk_t *hunks,
50+
int hunk_count, const char *file);
51+
4452
#endif

‎src/pipeline/pipeline.h‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
#include <stdint.h>
2020
#include <stdatomic.h>
2121

22-
#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
22+
#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
23+
#include "foundation/constants.h" /* CBM_SZ_512 */
2324

2425
/* Forward declarations */
2526
typedef struct cbm_store cbm_store_t;
@@ -290,4 +291,20 @@ cbm_fuzzy_result_t cbm_registry_fuzzy_resolve(const cbm_registry_t *r, const cha
290291

291292
const char *cbm_confidence_band(double score);
292293

294+
/* ── Git diff hunks (pass_gitdiff.c) ──────────────────────────────
295+
* Public (unlike the rest of pipeline_internal.h) because detect_changes
296+
* (src/mcp/mcp.c) scopes seed detection to changed line ranges, not just
297+
* changed files. */
298+
299+
typedef struct {
300+
char path[CBM_SZ_512];
301+
int start_line;
302+
int end_line;
303+
} cbm_changed_hunk_t;
304+
305+
/* Parse `git diff --unified=0` output into per-hunk (path, start_line,
306+
* end_line) entries — end_line is the last new-side line the hunk touches.
307+
* Returns count written to out (capped at max_out). */
308+
int cbm_parse_hunks(const char *output, cbm_changed_hunk_t *out, int max_out);
309+
293310
#endif /* CBM_PIPELINE_H */

‎src/pipeline/pipeline_internal.h‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -281,18 +281,13 @@ typedef struct {
281281
char old_path[CBM_SZ_512]; /* non-empty only for renames */
282282
} cbm_changed_file_t;
283283

284-
typedef struct {
285-
char path[CBM_SZ_512];
286-
int start_line;
287-
int end_line;
288-
} cbm_changed_hunk_t;
284+
/* cbm_changed_hunk_t + cbm_parse_hunks moved to pipeline.h (public — consumed
285+
* by src/mcp/mcp.c's detect_changes for line-scoped seed detection). Visible
286+
* here via the `#include "pipeline/pipeline.h"` above. */
289287

290288
/* Parse git diff --name-status output. Returns count written to out. */
291289
int cbm_parse_name_status(const char *output, cbm_changed_file_t *out, int max_out);
292290

293-
/* Parse git diff --unified=0 output. Returns count written to out. */
294-
int cbm_parse_hunks(const char *output, cbm_changed_hunk_t *out, int max_out);
295-
296291
/* Parse "start,count" or "start" → (start, count). */
297292
void cbm_parse_range(const char *s, int *out_start, int *out_count);
298293

0 commit comments

Comments
 (0)