@@ -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+
63124typedef struct {
64125 int64_t node_id ;
65126 char normalized [CBM_SZ_256 ];
@@ -70,6 +131,10 @@ typedef struct {
70131static 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}
0 commit comments