Skip to content

Commit f01a52d

Browse files
authored
Merge pull request #1470 from Studnicky/fix/configlink-deterministic-candidate-order
fix(configlink): order candidates canonically before the collector cap
2 parents 8d8c5a4 + 44881a0 commit f01a52d

2 files changed

Lines changed: 190 additions & 4 deletions

File tree

‎src/pipeline/pass_configlink.c‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,67 @@ static bool is_dep_section(const char *s) {
6060

6161
/* ── Strategy 1: Config Key → Code Symbol ───────────────────────── */
6262

63+
/* Canonical candidate order (determinism). Both collectors below fill a
64+
* fixed-capacity array and stop at max_out; the label indexes they walk are
65+
* gbuf insertion order = parallel-extraction merge order, which varies run to
66+
* run. On a repo with more candidates than the cap, sorting by a pure content
67+
* key first is what keeps the surviving set — and therefore the emitted
68+
* CONFIGURES edges — a function of the inputs rather than of worker
69+
* scheduling. Tie-breaks stay content-only: node ids are handed out in merge
70+
* order, so an id tie-break belongs in no canonical comparator. */
71+
enum {
72+
CANON_CMP_LESS = -1, /* qsort: left sorts before right */
73+
CANON_CMP_GREATER = 1, /* qsort: left sorts after right */
74+
CANON_CAP_BUF = 32 /* decimal rendering of a cap value */
75+
};
76+
77+
static int cmp_node_ptr_canonical(const void *pa, const void *pb) {
78+
const cbm_gbuf_node_t *a = *(const cbm_gbuf_node_t *const *)pa;
79+
const cbm_gbuf_node_t *b = *(const cbm_gbuf_node_t *const *)pb;
80+
const char *qa = a->qualified_name ? a->qualified_name : "";
81+
const char *qb = b->qualified_name ? b->qualified_name : "";
82+
int r = strcmp(qa, qb);
83+
if (r != 0) {
84+
return r;
85+
}
86+
const char *fa = a->file_path ? a->file_path : "";
87+
const char *fb = b->file_path ? b->file_path : "";
88+
r = strcmp(fa, fb);
89+
if (r != 0) {
90+
return r;
91+
}
92+
if (a->start_line != b->start_line) {
93+
return a->start_line < b->start_line ? CANON_CMP_LESS : CANON_CMP_GREATER;
94+
}
95+
const char *na = a->name ? a->name : "";
96+
const char *nb = b->name ? b->name : "";
97+
return strcmp(na, nb);
98+
}
99+
100+
/* A filled-to-capacity collector dropped candidates; say so rather than
101+
* truncating silently. */
102+
static void log_candidate_truncation(const char *side, int cap) {
103+
char cap_buf[CANON_CAP_BUF];
104+
snprintf(cap_buf, sizeof(cap_buf), "%d", cap);
105+
cbm_log_info("configlinker.truncated", "side", side, "cap", cap_buf);
106+
}
107+
108+
/* Heap copy of `nodes` sorted by cmp_node_ptr_canonical. Returns NULL (and
109+
* leaves the caller on the unsorted borrowed array) only on allocation
110+
* failure, which degrades determinism but never correctness. */
111+
static const cbm_gbuf_node_t **canonical_node_copy(const cbm_gbuf_node_t *const *nodes, int count) {
112+
if (!nodes || count <= 0) {
113+
return NULL;
114+
}
115+
const cbm_gbuf_node_t **sorted = malloc((size_t)count * sizeof(*sorted));
116+
if (!sorted) {
117+
return NULL;
118+
}
119+
memcpy(sorted, nodes, (size_t)count * sizeof(*sorted));
120+
qsort(sorted, (size_t)count, sizeof(*sorted), cmp_node_ptr_canonical);
121+
return sorted;
122+
}
123+
63124
typedef struct {
64125
int64_t node_id;
65126
char normalized[CBM_SZ_256];
@@ -70,6 +131,10 @@ typedef struct {
70131
static int collect_config_entries(const cbm_gbuf_node_t *const *vars, int var_count,
71132
config_entry_t *out, int max_out) {
72133
int n = 0;
134+
const cbm_gbuf_node_t **sorted = canonical_node_copy(vars, var_count);
135+
if (sorted) {
136+
vars = sorted;
137+
}
73138
for (int i = 0; i < var_count && n < max_out; i++) {
74139
if (!cbm_has_config_extension(vars[i]->file_path)) {
75140
continue;
@@ -102,6 +167,10 @@ static int collect_config_entries(const cbm_gbuf_node_t *const *vars, int var_co
102167
snprintf(out[n].name, sizeof(out[n].name), "%s", vars[i]->name);
103168
n++;
104169
}
170+
if (n == max_out) {
171+
log_candidate_truncation("config", max_out);
172+
}
173+
free((void *)sorted);
105174
return n;
106175
}
107176

@@ -124,22 +193,32 @@ static int collect_code_entries(cbm_gbuf_t *gb, code_entry_t *out, int max_out)
124193
continue;
125194
}
126195

196+
/* Canonical order before the cap — see cmp_node_ptr_canonical. The cap
197+
* spans the whole label list, so a later label can be cut mid-group;
198+
* sorting per group keeps that cut a pure function of content. */
199+
const cbm_gbuf_node_t **sorted = canonical_node_copy(nodes, count);
200+
const cbm_gbuf_node_t *const *scan = sorted ? sorted : nodes;
201+
127202
for (int i = 0; i < count && n < max_out; i++) {
128-
if (cbm_has_config_extension(nodes[i]->file_path)) {
203+
if (cbm_has_config_extension(scan[i]->file_path)) {
129204
continue;
130205
}
131206

132207
char norm[CBM_SZ_256];
133-
int tokens = cbm_normalize_config_key(nodes[i]->name, norm, sizeof(norm));
208+
int tokens = cbm_normalize_config_key(scan[i]->name, norm, sizeof(norm));
134209
if (tokens == 0 || norm[0] == '\0') {
135210
continue;
136211
}
137212

138-
out[n].node_id = nodes[i]->id;
213+
out[n].node_id = scan[i]->id;
139214
snprintf(out[n].normalized, sizeof(out[n].normalized), "%s", norm);
140215
n++;
141216
}
142-
/* gbuf data is borrowed — no free */
217+
/* gbuf data is borrowed — only the sorted copy is owned */
218+
free((void *)sorted);
219+
}
220+
if (n == max_out) {
221+
log_candidate_truncation("code", max_out);
143222
}
144223
return n;
145224
}

‎tests/test_configlink.c‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,110 @@ TEST(configlink_file_ref_no_false_positive) {
290290
PASS();
291291
}
292292

293+
/* ── Determinism: candidate truncation must not depend on insert order ── */
294+
295+
static int cl_cmp_row(const void *pa, const void *pb) {
296+
return strcmp(*(const char *const *)pa, *(const char *const *)pb);
297+
}
298+
299+
/* Collect the CONFIGURES edge set as a sorted, newline-joined
300+
* "src_qn|tgt_qn" fingerprint. Caller frees. */
301+
static char *configures_fingerprint(cbm_gbuf_t *gb) {
302+
const cbm_gbuf_edge_t **edges = NULL;
303+
int count = 0;
304+
cbm_gbuf_find_edges_by_type(gb, "CONFIGURES", &edges, &count);
305+
306+
char **rows = calloc((size_t)(count > 0 ? count : 1), sizeof(*rows));
307+
if (!rows) {
308+
return NULL;
309+
}
310+
int n = 0;
311+
for (int i = 0; i < count; i++) {
312+
const cbm_gbuf_node_t *s = cbm_gbuf_find_by_id(gb, edges[i]->source_id);
313+
const cbm_gbuf_node_t *t = cbm_gbuf_find_by_id(gb, edges[i]->target_id);
314+
if (!s || !t || !s->qualified_name || !t->qualified_name) {
315+
continue;
316+
}
317+
size_t len = strlen(s->qualified_name) + strlen(t->qualified_name) + 2;
318+
rows[n] = malloc(len);
319+
if (!rows[n]) {
320+
break;
321+
}
322+
snprintf(rows[n], len, "%s|%s", s->qualified_name, t->qualified_name);
323+
n++;
324+
}
325+
qsort(rows, (size_t)n, sizeof(*rows), cl_cmp_row);
326+
327+
size_t total = 1;
328+
for (int i = 0; i < n; i++) {
329+
total += strlen(rows[i]) + 1;
330+
}
331+
char *out = calloc(total, 1);
332+
if (out) {
333+
for (int i = 0; i < n; i++) {
334+
strcat(out, rows[i]);
335+
strcat(out, "\n");
336+
}
337+
}
338+
for (int i = 0; i < n; i++) {
339+
free(rows[i]);
340+
}
341+
free(rows);
342+
return out;
343+
}
344+
345+
/* Build a gbuf whose code-candidate count exceeds the collector's internal
346+
* cap, inserting the candidates either forwards or backwards. */
347+
enum { CL_DET_CANDIDATES = 9000 }; /* > the 8192 code-entry cap */
348+
349+
static cbm_gbuf_t *build_oversized_corpus(bool reverse) {
350+
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/test");
351+
352+
/* One config key every candidate can match on: "max_connections". */
353+
cbm_gbuf_upsert_node(gb, "Variable", "max_connections", "test.config.max_connections",
354+
"config.toml", 0, 0, NULL);
355+
356+
for (int k = 0; k < CL_DET_CANDIDATES; k++) {
357+
int i = reverse ? (CL_DET_CANDIDATES - 1 - k) : k;
358+
char name[CBM_SZ_64];
359+
char qn[CBM_SZ_128];
360+
char path[CBM_SZ_64];
361+
snprintf(name, sizeof(name), "getMaxConnections%04d", i);
362+
snprintf(qn, sizeof(qn), "test.mod%04d.%s", i, name);
363+
snprintf(path, sizeof(path), "mod%04d.go", i);
364+
cbm_gbuf_upsert_node(gb, "Function", name, qn, path, 0, 0, NULL);
365+
}
366+
return gb;
367+
}
368+
369+
/* REGRESSION: collect_config_entries/collect_code_entries fill fixed-capacity
370+
* arrays and stop at the cap, walking gbuf label indexes in insertion order —
371+
* which under parallel extraction is worker-merge order and varies run to run.
372+
* Sorting candidates canonically before the cap is what keeps the surviving
373+
* set, and therefore the emitted CONFIGURES edges, a pure function of the
374+
* inputs. This test stands in for the scheduling variance by feeding the same
375+
* corpus in two insertion orders: the emitted edge set must be identical. */
376+
TEST(configlink_candidate_truncation_is_order_independent) {
377+
cbm_gbuf_t *fwd = build_oversized_corpus(false);
378+
run_configlink(fwd, "test", NULL);
379+
char *fp_fwd = configures_fingerprint(fwd);
380+
381+
cbm_gbuf_t *rev = build_oversized_corpus(true);
382+
run_configlink(rev, "test", NULL);
383+
char *fp_rev = configures_fingerprint(rev);
384+
385+
ASSERT_TRUE(fp_fwd != NULL);
386+
ASSERT_TRUE(fp_rev != NULL);
387+
ASSERT_TRUE(fp_fwd[0] != '\0'); /* the cap must actually have been exercised */
388+
ASSERT_STR_EQ(fp_fwd, fp_rev);
389+
390+
free(fp_fwd);
391+
free(fp_rev);
392+
cbm_gbuf_free(fwd);
393+
cbm_gbuf_free(rev);
394+
PASS();
395+
}
396+
293397
/* ── Suite ───────────────────────────────────────────────────────── */
294398

295399
SUITE(configlink) {
@@ -305,4 +409,7 @@ SUITE(configlink) {
305409

306410
/* Strategy 3: File Path → Reference */
307411
RUN_TEST(configlink_file_ref_no_false_positive);
412+
413+
/* Determinism */
414+
RUN_TEST(configlink_candidate_truncation_is_order_independent);
308415
}

0 commit comments

Comments
 (0)