Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 140 additions & 9 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -9736,19 +9736,77 @@ static int mcp_run_shell_command_cancellable(cbm_mcp_server_t *srv, const char *
return contained ? 0 : -1;
}

/* Collect BFS seed ids: every symbol DEFINED in a changed file (everything but
* the structural container labels — those have no CALLS edges). These anchor
* the multi-source impact traversal. */
/* Does `node`'s line range overlap any recorded hunk for `file`? Used to scope
* seed detection to the actually-changed lines rather than the whole file.
* Non-static (declared in mcp_internal.h) so tests can exercise the overlap
* logic directly, matching this file's existing white-box test hooks. */
bool cbm_detect_node_in_hunks(const cbm_node_t *node, const cbm_changed_hunk_t *hunks,
int hunk_count, const char *file) {
for (int h = 0; h < hunk_count; h++) {
if (strcmp(hunks[h].path, file) == 0 && node->start_line <= hunks[h].end_line &&
node->end_line >= hunks[h].start_line) {
return true;
}
}
return false;
}

/* Collect BFS seed ids for one changed file (everything but the structural
* container labels — those have no CALLS edges). These anchor the
* multi-source impact traversal.
*
* When `hunks` has at least one entry for `file`, only definitions whose line
* range overlaps a hunk become seeds — a one-line edit inside a single
* function no longer seeds every other definition in the file. When no hunk
* is recorded for `file` (a brand-new/untracked file has no comparable
* "before" state, or the hunk fetch failed/was skipped), every non-container
* definition in the file is a seed — the previous, whole-file behavior — so
* this is a precision improvement, not a new failure mode. */
/* Structural container labels carry no CALLS edges and span the whole file, so
* they are never seeds. Shared by the seeding loop and the overlap probe. */
static bool detect_is_seedable_label(const char *lb) {
return lb && strcmp(lb, "File") != 0 && strcmp(lb, "Folder") != 0 &&
strcmp(lb, "Project") != 0 && strcmp(lb, "Module") != 0 && strcmp(lb, "Package") != 0 &&
strcmp(lb, "Section") != 0;
}

static void detect_collect_seeds(cbm_store_t *store, const char *project, const char *file,
int64_t **seeds, int *n, int *cap) {
const cbm_changed_hunk_t *hunks, int hunk_count, int64_t **seeds,
int *n, int *cap) {
cbm_node_t *nodes = NULL;
int ncount = 0;
cbm_store_find_nodes_by_file(store, project, file, &nodes, &ncount);
bool scope_to_hunks = false;
for (int h = 0; h < hunk_count; h++) {
if (strcmp(hunks[h].path, file) == 0) {
scope_to_hunks = true;
break;
}
}
/* A file can have hunks yet no SEEDABLE definition overlapping any of them:
* an import-only edit, a module-level constant, or a change above the first
* definition all land outside every definition's line range. Scoping would
* then drop the file from the seed set entirely — strictly worse recall
* than the whole-file behavior this replaces. Probe for an overlap first
* and keep whole-file seeding for that file when there is none.
*
* The probe must apply the same label filter as the seeding loop below:
* container nodes span the whole file (a Module node is lines 1..EOF), so
* counting them would report an overlap for every hunk and defeat the
* fallback entirely. */
if (scope_to_hunks) {
bool any_overlap = false;
for (int i = 0; i < ncount && !any_overlap; i++) {
any_overlap = detect_is_seedable_label(nodes[i].label) &&
cbm_detect_node_in_hunks(&nodes[i], hunks, hunk_count, file);
}
scope_to_hunks = any_overlap;
}
for (int i = 0; i < ncount; i++) {
const char *lb = nodes[i].label;
if (lb && strcmp(lb, "File") != 0 && strcmp(lb, "Folder") != 0 &&
strcmp(lb, "Project") != 0 && strcmp(lb, "Module") != 0 && strcmp(lb, "Package") != 0 &&
strcmp(lb, "Section") != 0) {
if (detect_is_seedable_label(nodes[i].label)) {
if (scope_to_hunks && !cbm_detect_node_in_hunks(&nodes[i], hunks, hunk_count, file)) {
continue;
}
if (*n >= *cap) {
*cap = *cap ? *cap * 2 : 16;
*seeds = safe_realloc(*seeds, (size_t)*cap * sizeof(int64_t));
Expand Down Expand Up @@ -10035,6 +10093,76 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
int seed_count = 0;
int seed_cap = 0;

/* Hunk line ranges (unified=0 diff), used to scope seed detection to the
* actually-changed lines instead of every definition in a changed file
* (see detect_collect_seeds). Best-effort: any failure here just leaves
* `hunks` empty and every file falls back to its previous whole-file
* seeding — this is a precision improvement, not a correctness
* dependency, so it is never treated as a request-level failure.
*
* Coordinate systems: `base...HEAD` hunks carry HEAD-side line numbers,
* the worktree diff carries worktree-side ones, and node line ranges come
* from the indexed snapshot. These agree while the index is fresh — the
* watcher reindexes on HEAD movement and on a dirty tree — but a stale
* index combined with insertions earlier in the file shifts the node lines
* relative to the hunks and can mis-scope. The failure is bounded by
* detect_collect_seeds' zero-overlap fallback: a file whose definitions all
* miss reverts to whole-file seeding rather than dropping out. */
cbm_changed_hunk_t *hunks = NULL;
int hunk_count = 0;
if (want_symbols) {
char hunk_cmd[CBM_SZ_2K];
#ifdef _WIN32
snprintf(hunk_cmd, sizeof(hunk_cmd),
"git -C \"%s\" diff --unified=0 \"%s\"...HEAD 2>NUL & "
"git -C \"%s\" diff --unified=0 2>NUL",
root_path, base_branch, root_path);
#else
snprintf(hunk_cmd, sizeof(hunk_cmd),
"{ git -C '%s' diff --unified=0 '%s'...HEAD 2>/dev/null; "
"git -C '%s' diff --unified=0 2>/dev/null; }",
root_path, base_branch, root_path);
#endif
char hunk_output_path[CBM_SZ_2K] = {0};
cbm_proc_result_t hunk_result = {0};
int hunk_run =
mcp_run_shell_command_cancellable(srv, hunk_cmd, hunk_output_path, &hunk_result);
bool hunk_cancelled = hunk_result.cancellation_requested || mcp_request_cancelled(srv);
FILE *hfp = (!hunk_cancelled && hunk_run == 0) ? cbm_fopen(hunk_output_path, "rb") : NULL;
if (hfp) {
(void)fseek(hfp, 0, SEEK_END);
long hsz = ftell(hfp);
if (hsz > 0) {
(void)fseek(hfp, 0, SEEK_SET);
char *hbuf = malloc((size_t)hsz + SKIP_ONE);
if (hbuf) {
size_t hread = fread(hbuf, SKIP_ONE, (size_t)hsz, hfp);
hbuf[hread] = '\0';
enum { HUNK_CAP = 4096 };
hunks = safe_realloc(NULL, (size_t)HUNK_CAP * sizeof(cbm_changed_hunk_t));
hunk_count = cbm_parse_hunks(hbuf, hunks, HUNK_CAP);
/* A filled buffer means the diff was truncated: the hunks
* past the cap are gone, so files captured only partially
* would still look scoped and silently under-seed. Drop
* scoping for the whole request rather than under-report a
* large refactor — whole-file seeding is the safe side. */
if (hunk_count >= HUNK_CAP) {
cbm_log_info("detect_changes.hunks", "action", "scoping_disabled", "reason",
"hunk_cap_reached");
free(hunks);
hunks = NULL;
hunk_count = 0;
}
free(hbuf);
}
}
(void)fclose(hfp);
}
if (hunk_output_path[0]) {
(void)cbm_unlink(hunk_output_path);
}
}

char line[CBM_SZ_1K];
while (fgets(line, sizeof(line), fp)) {
size_t len = strlen(line);
Expand Down Expand Up @@ -10077,7 +10205,8 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
}
files[file_count++] = heap_strdup(path_line);
if (want_symbols) {
detect_collect_seeds(store, project, path_line, &seeds, &seed_count, &seed_cap);
detect_collect_seeds(store, project, path_line, hunks, hunk_count, &seeds, &seed_count,
&seed_cap);
}
}
(void)fclose(fp);
Expand Down Expand Up @@ -10123,6 +10252,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
}
free(files);
free(seeds);
free(hunks);
free(direction);
free(root_path);
free(project);
Expand Down Expand Up @@ -10280,6 +10410,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
}
free(files);
free(seeds);
free(hunks);
free(direction);
free(root_path);
free(project);
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/mcp_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#define CBM_MCP_INTERNAL_H

#include "mcp/mcp.h"
#include "pipeline/pipeline.h" /* cbm_changed_hunk_t */
#include "store/store.h" /* cbm_node_t */

/* White-box fault injection for deterministic cross-platform quarantine
* safety tests. This header is internal and is not part of the MCP API. */
Expand Down Expand Up @@ -31,4 +33,10 @@ enum { CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT = 50000 };
bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit,
int *file_count_out);

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

#endif
19 changes: 18 additions & 1 deletion src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
#include <stdint.h>
#include <stdatomic.h>

#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
#include "discover/discover.h" /* cbm_ignored_file_t (#963) */
#include "foundation/constants.h" /* CBM_SZ_512 */

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

const char *cbm_confidence_band(double score);

/* ── Git diff hunks (pass_gitdiff.c) ──────────────────────────────
* Public (unlike the rest of pipeline_internal.h) because detect_changes
* (src/mcp/mcp.c) scopes seed detection to changed line ranges, not just
* changed files. */

typedef struct {
char path[CBM_SZ_512];
int start_line;
int end_line;
} cbm_changed_hunk_t;

/* Parse `git diff --unified=0` output into per-hunk (path, start_line,
* end_line) entries — end_line is the last new-side line the hunk touches.
* Returns count written to out (capped at max_out). */
int cbm_parse_hunks(const char *output, cbm_changed_hunk_t *out, int max_out);

#endif /* CBM_PIPELINE_H */
11 changes: 3 additions & 8 deletions src/pipeline/pipeline_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -281,18 +281,13 @@ typedef struct {
char old_path[CBM_SZ_512]; /* non-empty only for renames */
} cbm_changed_file_t;

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

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

/* Parse git diff --unified=0 output. Returns count written to out. */
int cbm_parse_hunks(const char *output, cbm_changed_hunk_t *out, int max_out);

/* Parse "start,count" or "start" → (start, count). */
void cbm_parse_range(const char *s, int *out_start, int *out_count);

Expand Down
Loading
Loading