@@ -92,9 +92,15 @@ pub const DiagnosticAggregator = struct {
9292 /// Pattern aggregation: tracks (issue_kind, pattern_base) → count
9393 pattern_counts : std .AutoHashMap (u64 , PatternInfo ),
9494
95+ /// Pending diagnostics for deferred bucketing in flush()
96+ pending : std .ArrayList (PendingDiag ),
97+
9598 /// Threshold for pattern folding (fold when count exceeds this)
9699 const PATTERN_FOLD_THRESHOLD : usize = 3 ;
97100
101+ /// Threshold for message-based folding (same kind+message across functions)
102+ const MESSAGE_FOLD_THRESHOLD : usize = 5 ;
103+
98104 /// Pattern information for aggregation
99105 const PatternInfo = struct {
100106 kind_tag : []const u8 ,
@@ -104,13 +110,24 @@ pub const DiagnosticAggregator = struct {
104110 last_func_name : []const u8 ,
105111 };
106112
113+ /// Pending diagnostic for deferred bucketing in flush()
114+ const PendingDiag = struct {
115+ kind_tag : []const u8 ,
116+ message : []const u8 ,
117+ func_name : []const u8 ,
118+ severity : OutputSeverity ,
119+ loc : u32 ,
120+ confidence : f32 ,
121+ };
122+
107123 /// Create a new diagnostic aggregator
108124 pub fn init (allocator : std.mem.Allocator ) ! DiagnosticAggregator {
109125 return .{
110126 .allocator = allocator ,
111127 .diagnostics = try std .ArrayList (Diagnostic ).initCapacity (allocator , 0 ),
112128 .seen_keys = std .AutoHashMap (u64 , void ).init (allocator ),
113129 .pattern_counts = std .AutoHashMap (u64 , PatternInfo ).init (allocator ),
130+ .pending = try std .ArrayList (PendingDiag ).initCapacity (allocator , 0 ),
114131 };
115132 }
116133
@@ -129,6 +146,8 @@ pub const DiagnosticAggregator = struct {
129146 self .allocator .free (entry .value_ptr .last_func_name );
130147 }
131148 self .pattern_counts .deinit ();
149+
150+ self .pending .deinit (self .allocator );
132151 }
133152
134153 /// Add a diagnostic with cross-pass deduplication
@@ -207,9 +226,6 @@ pub const DiagnosticAggregator = struct {
207226 else
208227 0.5 ;
209228
210- // Map issue kind to diagnostic kind (preserve semantic info)
211- const diag_kind = mapKindToDiagnostic (kind_tag );
212-
213229 // Preserve location info when available.
214230 // Handle both u32 and ?u32 for line field (test compatibility).
215231 const loc_id : u32 = blk : {
@@ -226,15 +242,19 @@ pub const DiagnosticAggregator = struct {
226242 }
227243 };
228244
229- try self .add (.{
230- .kind = diag_kind ,
231- .severity = if (conf >= 0.8 ) .err else if (conf >= 0.5 ) .warning else .info ,
245+ const severity : OutputSeverity = if (conf >= 0.8 ) .err else if (conf >= 0.5 ) .warning else .info ;
246+
247+ // Push to pending list for deferred bucketing in flush()
248+ try self .pending .append (self .allocator , .{
249+ .kind_tag = kind_tag ,
250+ .message = try self .allocator .dupe (u8 , msg ),
251+ .func_name = try self .allocator .dupe (u8 , func_name ),
252+ .severity = severity ,
232253 .loc = loc_id ,
233- .message = msg ,
234254 .confidence = conf ,
235255 });
236256
237- // Pattern-based aggregation: detect and fold repetitive patterns
257+ // Pattern-based aggregation: detect and track repetitive patterns
238258 // (e.g., ffi_alloc_1, ffi_alloc_2, ... ffi_alloc_20)
239259 if (extractPatternBase (func_name )) | pattern_base | {
240260 const pkey = patternHashKey (kind_tag , pattern_base );
@@ -256,34 +276,126 @@ pub const DiagnosticAggregator = struct {
256276 // Free old last_func_name before updating
257277 self .allocator .free (pattern_gop .value_ptr .last_func_name );
258278 pattern_gop .value_ptr .last_func_name = try self .allocator .dupe (u8 , func_name );
279+ }
280+ }
259281
260- // Check if we should generate a folded summary
261- if (pattern_gop .value_ptr .count == PATTERN_FOLD_THRESHOLD + 1 ) {
282+ return true ;
283+ }
284+
285+ /// Flush pending diagnostics with deferred bucketing and aggregation.
286+ ///
287+ /// Processes the pending list accumulated by addIssue():
288+ /// 1. Emits folded diagnostics for patterns exceeding PATTERN_FOLD_THRESHOLD
289+ /// 2. Groups pending diagnostics by (kind_tag + message) hash key
290+ /// 3. For groups > MESSAGE_FOLD_THRESHOLD, emits ONE aggregated diagnostic
291+ /// 4. For groups <= MESSAGE_FOLD_THRESHOLD, emits each diagnostic individually
292+ /// 5. Clears the pending list
293+ pub fn flush (self : * DiagnosticAggregator ) ! void {
294+ // 1. Emit pattern-folded diagnostics for patterns exceeding threshold
295+ {
296+ var it = self .pattern_counts .iterator ();
297+ while (it .next ()) | entry | {
298+ if (entry .value_ptr .count > PATTERN_FOLD_THRESHOLD ) {
262299 const fold_msg = try std .fmt .allocPrint (
263300 self .allocator ,
264301 "[{s}×{d}] {s}{{1..{d}}} — {d} identical patterns" ,
265302 .{
266- kind_tag ,
267- pattern_gop .value_ptr .count ,
268- pattern_gop .value_ptr .pattern_base ,
269- pattern_gop .value_ptr .count ,
270- pattern_gop .value_ptr .count ,
303+ entry . value_ptr . kind_tag ,
304+ entry .value_ptr .count ,
305+ entry .value_ptr .pattern_base ,
306+ entry .value_ptr .count ,
307+ entry .value_ptr .count ,
271308 },
272309 );
273310 defer self .allocator .free (fold_msg );
274311
275312 try self .add (.{
276- .kind = diag_kind ,
277- .severity = if ( conf >= 0.8 ) .err else if ( conf >= 0.5 ) . warning else .info ,
278- .loc = loc_id ,
313+ .kind = mapKindToDiagnostic ( entry . value_ptr . kind_tag ) ,
314+ .severity = . warning ,
315+ .loc = 0 ,
279316 .message = fold_msg ,
280- .confidence = conf ,
317+ .confidence = 0.8 ,
281318 });
282319 }
283320 }
284321 }
285322
286- return true ;
323+ // If nothing pending, nothing more to do
324+ if (self .pending .items .len == 0 ) return ;
325+
326+ // 2. Build group counts by (kind_tag + message) hash key
327+ var group_counts = std .AutoHashMap (u64 , usize ).init (self .allocator );
328+ defer group_counts .deinit ();
329+
330+ for (self .pending .items ) | diag | {
331+ var hasher = std .hash .Fnv1a_64 .init ();
332+ hasher .update (diag .kind_tag );
333+ hasher .update (diag .message );
334+ const key = hasher .final ();
335+
336+ const gop = try group_counts .getOrPut (key );
337+ if (! gop .found_existing ) {
338+ gop .value_ptr .* = 1 ;
339+ } else {
340+ gop .value_ptr .* += 1 ;
341+ }
342+ }
343+
344+ // 3. Track which aggregated groups have already been emitted
345+ var emitted_groups = std .AutoHashMap (u64 , void ).init (self .allocator );
346+ defer emitted_groups .deinit ();
347+
348+ // 4. Iterate pending and emit individual or aggregated diagnostics
349+ for (self .pending .items ) | diag | {
350+ var hasher = std .hash .Fnv1a_64 .init ();
351+ hasher .update (diag .kind_tag );
352+ hasher .update (diag .message );
353+ const key = hasher .final ();
354+
355+ const count = group_counts .get (key ).? ;
356+
357+ if (count > MESSAGE_FOLD_THRESHOLD ) {
358+ // Emit aggregated diagnostic once per group
359+ const gop = try emitted_groups .getOrPut (key );
360+ if (! gop .found_existing ) {
361+ const fold_msg = try std .fmt .allocPrint (
362+ self .allocator ,
363+ "[aggregated] {s} ×{d} ({s} and {d} other functions)" ,
364+ .{
365+ diag .kind_tag ,
366+ count ,
367+ diag .func_name ,
368+ count - 1 ,
369+ },
370+ );
371+ defer self .allocator .free (fold_msg );
372+
373+ try self .add (.{
374+ .kind = mapKindToDiagnostic (diag .kind_tag ),
375+ .severity = diag .severity ,
376+ .loc = diag .loc ,
377+ .message = fold_msg ,
378+ .confidence = diag .confidence ,
379+ });
380+ }
381+ } else {
382+ // Emit individually
383+ try self .add (.{
384+ .kind = mapKindToDiagnostic (diag .kind_tag ),
385+ .severity = diag .severity ,
386+ .loc = diag .loc ,
387+ .message = diag .message ,
388+ .confidence = diag .confidence ,
389+ });
390+ }
391+ }
392+
393+ // 5. Clear pending list
394+ for (self .pending .items ) | diag | {
395+ self .allocator .free (diag .message );
396+ self .allocator .free (diag .func_name );
397+ }
398+ self .pending .clearRetainingCapacity ();
287399 }
288400
289401 fn mapKindToDiagnostic (kind_str : []const u8 ) DiagnosticKind {
@@ -453,6 +565,13 @@ pub const DiagnosticAggregator = struct {
453565 self .allocator .free (entry .value_ptr .last_func_name );
454566 }
455567 self .pattern_counts .clearRetainingCapacity ();
568+
569+ // Free pending items and clear
570+ for (self .pending .items ) | diag | {
571+ self .allocator .free (diag .message );
572+ self .allocator .free (diag .func_name );
573+ }
574+ self .pending .clearRetainingCapacity ();
456575 }
457576
458577 /// Extract pattern base from function name by detecting numeric suffixes.
@@ -899,6 +1018,9 @@ test "DiagnosticAggregator - pattern aggregation" {
8991018 _ = try aggregator .addIssue (issue );
9001019 }
9011020
1021+ // Flush pending to process pattern folding
1022+ try aggregator .flush ();
1023+
9021024 // Should have individual issues + 1 folded summary
9031025 const all_diags = aggregator .getAll ();
9041026
0 commit comments