fiber_audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.fiber-audit.example.yml +33 -0
  3. data/CHANGELOG.md +32 -0
  4. data/README.md +150 -0
  5. data/bin/fiber-audit +5 -0
  6. data/lib/fiber_audit/audit.rb +288 -0
  7. data/lib/fiber_audit/cli.rb +245 -0
  8. data/lib/fiber_audit/configuration.rb +236 -0
  9. data/lib/fiber_audit/correlation/fingerprint.rb +26 -0
  10. data/lib/fiber_audit/errors.rb +8 -0
  11. data/lib/fiber_audit/execution_context.rb +47 -0
  12. data/lib/fiber_audit/findings/collection.rb +58 -0
  13. data/lib/fiber_audit/findings/confidence.rb +19 -0
  14. data/lib/fiber_audit/findings/evidence.rb +13 -0
  15. data/lib/fiber_audit/findings/finding.rb +76 -0
  16. data/lib/fiber_audit/findings/location.rb +9 -0
  17. data/lib/fiber_audit/findings/severity.rb +19 -0
  18. data/lib/fiber_audit/project.rb +96 -0
  19. data/lib/fiber_audit/reporters/base.rb +12 -0
  20. data/lib/fiber_audit/reporters/json.rb +34 -0
  21. data/lib/fiber_audit/reporters/schema.rb +574 -0
  22. data/lib/fiber_audit/reporters/text.rb +179 -0
  23. data/lib/fiber_audit/static/call_site.rb +71 -0
  24. data/lib/fiber_audit/static/call_site_extractor.rb +524 -0
  25. data/lib/fiber_audit/static/execution_context_resolver.rb +266 -0
  26. data/lib/fiber_audit/static/rules/base.rb +185 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +94 -0
  28. data/lib/fiber_audit/static/rules/built_ins.rb +36 -0
  29. data/lib/fiber_audit/static/rules/direct_socket.rb +112 -0
  30. data/lib/fiber_audit/static/rules/io_select.rb +104 -0
  31. data/lib/fiber_audit/static/rules/net_http_in_request.rb +116 -0
  32. data/lib/fiber_audit/static/rules/registry.rb +123 -0
  33. data/lib/fiber_audit/static/rules/synchronization.rb +124 -0
  34. data/lib/fiber_audit/static/rules/thread_current_state.rb +113 -0
  35. data/lib/fiber_audit/static/rules/thread_join.rb +96 -0
  36. data/lib/fiber_audit/static/semantic_index.rb +300 -0
  37. data/lib/fiber_audit/suppressions/parser.rb +146 -0
  38. data/lib/fiber_audit/suppressions/store.rb +63 -0
  39. data/lib/fiber_audit/version.rb +5 -0
  40. data/lib/fiber_audit.rb +40 -0
  41. metadata +108 -0
@@ -0,0 +1,574 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative '../version'
5
+ require_relative '../findings/severity'
6
+ require_relative '../findings/confidence'
7
+
8
+ module FiberAudit
9
+ module Reporters
10
+ # Schema validates audit results and builds deterministic output hashes.
11
+ # It enforces the publication invariant and structural constraints.
12
+ #
13
+ # Architecture:
14
+ # - build(result): Converts result protocol to normalized primitive report Hash
15
+ # - validate!(report_hash): Validates that Hash and returns it
16
+ # - JSON/Text call build then validate
17
+ # The complete external schema is centralized here so validation cannot
18
+ # drift across reporters.
19
+ # rubocop:disable Metrics/ModuleLength
20
+ module Schema
21
+ SCHEMA_VERSION = '1.0'
22
+ DISCLAIMER = 'This is a static-only audit. PASS cannot be granted without runtime coverage.'
23
+ ALLOWED_STATUSES = %w[FAIL REVIEW PASS_WITH_WARNINGS NO_FINDINGS].freeze
24
+ ALLOWED_SEVERITIES = %w[critical high medium low info].freeze
25
+ ALLOWED_CONFIDENCES = %w[confirmed high medium low unknown].freeze
26
+
27
+ module_function
28
+
29
+ # Builds a normalized primitive report hash from result protocol.
30
+ # Normalizes Finding/Evidence/Location objects to JSON primitives.
31
+ def build(result)
32
+ validate_interface!(result)
33
+
34
+ status = validate_status(result.status)
35
+ findings = normalize_findings(result.findings)
36
+ suppressed = normalize_findings(result.suppressed)
37
+ parse_errors = normalize_parse_errors(result.parse_errors)
38
+ coverage = normalize_coverage(result.coverage)
39
+
40
+ summary = build_summary(findings, suppressed)
41
+
42
+ {
43
+ schema_version: SCHEMA_VERSION,
44
+ tool_version: FiberAudit::VERSION,
45
+ status: status,
46
+ disclaimer: DISCLAIMER,
47
+ summary: summary,
48
+ coverage: coverage,
49
+ findings: sort_findings(findings),
50
+ suppressed: sort_findings(suppressed),
51
+ parse_errors: parse_errors
52
+ }
53
+ end
54
+
55
+ # Validates a report hash and returns it.
56
+ # Raises ReporterError on any validation failure.
57
+ def validate!(report_hash)
58
+ validate_report_structure!(report_hash)
59
+ validate_status_value!(report_hash[:status])
60
+ validate_summary!(report_hash[:summary])
61
+ validate_coverage!(report_hash[:coverage])
62
+ validate_findings_array!(report_hash[:findings], 'findings')
63
+ validate_findings_array!(report_hash[:suppressed], 'suppressed')
64
+ validate_parse_errors!(report_hash[:parse_errors])
65
+ verify_summary_consistency!(report_hash)
66
+
67
+ report_hash.freeze
68
+ end
69
+
70
+ def validate_interface!(result)
71
+ %i[findings suppressed parse_errors coverage status].each do |method|
72
+ next if result.respond_to?(method)
73
+
74
+ raise ReporterError, "result must respond to #{method}"
75
+ end
76
+ end
77
+
78
+ def validate_status(status)
79
+ status_str = status.to_s.upcase
80
+ return status_str if ALLOWED_STATUSES.include?(status_str)
81
+
82
+ raise ReporterError, "invalid status: #{status.inspect}, must be one of #{ALLOWED_STATUSES.join(', ')}"
83
+ end
84
+
85
+ def validate_report_structure!(report_hash)
86
+ required_keys = %i[schema_version tool_version status disclaimer summary coverage findings suppressed parse_errors]
87
+ actual_keys = report_hash.keys
88
+
89
+ missing = required_keys - actual_keys
90
+ raise ReporterError, "report missing required keys: #{missing.join(', ')}" unless missing.empty?
91
+
92
+ unknown = actual_keys - required_keys
93
+ return if unknown.empty?
94
+
95
+ raise ReporterError, "report has unknown keys: #{unknown.join(', ')}"
96
+ end
97
+
98
+ def validate_status_value!(status)
99
+ return if status.is_a?(String) && ALLOWED_STATUSES.include?(status)
100
+
101
+ raise ReporterError, "invalid status: #{status.inspect}"
102
+ end
103
+
104
+ def validate_summary!(summary)
105
+ required_keys = %i[critical high medium low info suppressed total]
106
+ actual_keys = summary.keys
107
+
108
+ missing = required_keys - actual_keys
109
+ raise ReporterError, "summary missing required keys: #{missing.join(', ')}" unless missing.empty?
110
+
111
+ unknown = actual_keys - required_keys
112
+ raise ReporterError, "summary has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
113
+
114
+ required_keys.each do |key|
115
+ value = summary[key]
116
+ raise ReporterError, "summary.#{key} must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
117
+ end
118
+ end
119
+
120
+ def normalize_findings(findings_array)
121
+ collection = findings_array.respond_to?(:to_a) ? findings_array.to_a : Array(findings_array)
122
+ collection.map { |finding| normalize_finding(finding) }
123
+ end
124
+
125
+ def normalize_finding(finding)
126
+ validate_finding_structure!(finding)
127
+
128
+ rule_id = finding.rule_id.to_s
129
+ message = finding.message.to_s
130
+ fingerprint = finding.fingerprint.to_s
131
+ severity = finding.severity.to_s
132
+ confidence = finding.confidence.to_s
133
+
134
+ validate_finding_strings!(rule_id, message, fingerprint, severity, confidence)
135
+
136
+ location = normalize_location(finding.location)
137
+ evidence = normalize_evidence(finding.evidence, rule_id)
138
+
139
+ {
140
+ rule_id: rule_id,
141
+ title: normalize_optional_field(finding.title, 'title'),
142
+ category: normalize_optional_field(finding.category, 'category'),
143
+ severity: severity,
144
+ confidence: confidence,
145
+ location: location,
146
+ symbol: normalize_optional_field(finding.symbol, 'symbol'),
147
+ operation: normalize_optional_field(finding.operation, 'operation'),
148
+ execution_context: normalize_optional_field(finding.execution_context, 'execution_context'),
149
+ message: message,
150
+ evidence: evidence,
151
+ remediation: normalize_optional_field(finding.remediation, 'remediation'),
152
+ fingerprint: fingerprint
153
+ }
154
+ end
155
+
156
+ def validate_finding_structure!(finding)
157
+ %i[rule_id severity confidence message evidence fingerprint].each do |method|
158
+ next if finding.respond_to?(method)
159
+
160
+ raise ReporterError, "finding must respond to #{method}"
161
+ end
162
+ end
163
+
164
+ def validate_finding_strings!(rule_id, message, fingerprint, severity, confidence)
165
+ raise ReporterError, 'finding rule_id must be non-empty' if rule_id.empty?
166
+
167
+ raise ReporterError, 'finding message must be non-empty' if message.empty?
168
+
169
+ raise ReporterError, 'finding fingerprint must be non-empty' if fingerprint.empty?
170
+
171
+ raise ReporterError, "finding has invalid severity: #{severity.inspect}" unless ALLOWED_SEVERITIES.include?(severity)
172
+
173
+ return if ALLOWED_CONFIDENCES.include?(confidence)
174
+
175
+ raise ReporterError, "finding has invalid confidence: #{confidence.inspect}"
176
+ end
177
+
178
+ def normalize_optional_field(value, field)
179
+ return nil if value.nil?
180
+ return value.to_s if value.is_a?(Symbol)
181
+ return value if json_safe?(value)
182
+
183
+ raise ReporterError, "finding.#{field} is not JSON-safe: #{value.class}"
184
+ end
185
+
186
+ def normalize_location(location)
187
+ return nil if location.nil?
188
+ return nil unless location.respond_to?(:path)
189
+
190
+ path = location.path.to_s
191
+ line = location.respond_to?(:line) ? location.line : nil
192
+ column = location.respond_to?(:column) ? location.column : nil
193
+
194
+ validate_location!(path, line, column)
195
+
196
+ { path: path, line: line, column: column }
197
+ end
198
+
199
+ def validate_location!(path, line, column)
200
+ raise ReporterError, 'location path must be non-empty string' if path.empty?
201
+
202
+ unless line.nil? || (line.is_a?(Integer) && line.positive?)
203
+ raise ReporterError, "location line must be positive Integer or nil, got: #{line.inspect}"
204
+ end
205
+
206
+ return if column.nil? || (column.is_a?(Integer) && column >= 0)
207
+
208
+ raise ReporterError, "location column must be non-negative Integer or nil, got: #{column.inspect}"
209
+ end
210
+
211
+ def normalize_evidence(evidence_array, rule_id)
212
+ collection = evidence_array.respond_to?(:to_a) ? evidence_array.to_a : Array(evidence_array)
213
+
214
+ raise ReporterError, "Finding (#{rule_id}) must have non-empty evidence (publication invariant)" if collection.empty?
215
+
216
+ collection.map.with_index do |ev, idx|
217
+ normalize_evidence_record(ev, rule_id, idx)
218
+ end
219
+ end
220
+
221
+ def normalize_evidence_record(evidence, rule_id, idx)
222
+ %i[source message].each do |method|
223
+ next if evidence.respond_to?(method)
224
+
225
+ raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}] must respond to #{method}"
226
+ end
227
+
228
+ source = evidence.source.to_s
229
+ message = evidence.message.to_s
230
+
231
+ raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}].source must be non-empty" if source.empty?
232
+
233
+ raise ReporterError, "Finding (#{rule_id}) evidence[#{idx}].message must be non-empty" if message.empty?
234
+
235
+ details = evidence.respond_to?(:details) ? evidence.details : {}
236
+ normalized_details = normalize_json_value(details, rule_id, idx)
237
+
238
+ { source: source, message: message, details: normalized_details }
239
+ end
240
+
241
+ def normalize_json_value(value, rule_id, evidence_index)
242
+ case value
243
+ when Symbol
244
+ value.to_s
245
+ when Hash
246
+ value.each_with_object({}) do |(key, nested), normalized|
247
+ unless key.is_a?(String) || key.is_a?(Symbol)
248
+ raise ReporterError, details_error(rule_id, evidence_index, key.class)
249
+ end
250
+
251
+ normalized[key.to_s] = normalize_json_value(nested, rule_id, evidence_index)
252
+ end
253
+ when Array
254
+ value.map { |nested| normalize_json_value(nested, rule_id, evidence_index) }
255
+ else
256
+ return value if json_safe?(value)
257
+
258
+ raise ReporterError, details_error(rule_id, evidence_index, value.class)
259
+ end
260
+ end
261
+
262
+ def details_error(rule_id, evidence_index, value_class)
263
+ "Finding (#{rule_id}) evidence[#{evidence_index}].details is not JSON-safe: #{value_class}"
264
+ end
265
+
266
+ def json_safe?(value)
267
+ value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?
268
+ end
269
+
270
+ def json_safe_recursive?(value)
271
+ case value
272
+ when Hash
273
+ value.all? { |k, v| json_safe?(k) && json_safe_recursive?(v) }
274
+ when Array
275
+ value.all? { |item| json_safe_recursive?(item) }
276
+ else
277
+ json_safe?(value)
278
+ end
279
+ end
280
+
281
+ def normalize_parse_errors(parse_errors_array)
282
+ collection = parse_errors_array.respond_to?(:to_a) ? parse_errors_array.to_a : Array(parse_errors_array)
283
+ collection.map { |error| normalize_parse_error(error) }
284
+ end
285
+
286
+ def normalize_parse_error(error)
287
+ unless error.respond_to?(:path) && error.respond_to?(:message)
288
+ raise ReporterError, 'parse error must respond to :path and :message'
289
+ end
290
+
291
+ path = error.path.to_s
292
+ message = error.message.to_s
293
+
294
+ raise ReporterError, 'parse error path must be non-empty' if path.empty?
295
+
296
+ raise ReporterError, 'parse error message must be non-empty' if message.empty?
297
+
298
+ line = error.respond_to?(:line) ? error.line : nil
299
+
300
+ unless line.nil? || (line.is_a?(Integer) && line.positive?)
301
+ raise ReporterError, "parse error line must be positive Integer or nil, got: #{line.inspect}"
302
+ end
303
+
304
+ { path: path, message: message, line: line }
305
+ end
306
+
307
+ def normalize_coverage(coverage)
308
+ unless coverage.respond_to?(:analysed_files) &&
309
+ coverage.respond_to?(:total_call_sites) &&
310
+ coverage.respond_to?(:rules_run)
311
+ raise ReporterError, 'coverage must respond to :analysed_files, :total_call_sites, :rules_run'
312
+ end
313
+
314
+ analysed_files = coverage.analysed_files
315
+ total_call_sites = coverage.total_call_sites
316
+ rules_run = coverage.rules_run
317
+
318
+ validate_non_negative_integer!(analysed_files, 'coverage.analysed_files')
319
+ validate_non_negative_integer!(total_call_sites, 'coverage.total_call_sites')
320
+ validate_non_negative_integer!(rules_run, 'coverage.rules_run')
321
+
322
+ {
323
+ analysed_files: analysed_files,
324
+ total_call_sites: total_call_sites,
325
+ rules_run: rules_run
326
+ }
327
+ end
328
+
329
+ def validate_coverage!(coverage)
330
+ required_keys = %i[analysed_files total_call_sites rules_run]
331
+ actual_keys = coverage.keys
332
+
333
+ missing = required_keys - actual_keys
334
+ raise ReporterError, "coverage missing required keys: #{missing.join(', ')}" unless missing.empty?
335
+
336
+ unknown = actual_keys - required_keys
337
+ raise ReporterError, "coverage has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
338
+
339
+ required_keys.each do |key|
340
+ value = coverage[key]
341
+ validate_non_negative_integer!(value, "coverage.#{key}")
342
+ end
343
+ end
344
+
345
+ def verify_summary_consistency!(report_hash)
346
+ summary = report_hash[:summary]
347
+ findings = report_hash[:findings]
348
+ suppressed = report_hash[:suppressed]
349
+
350
+ counts = Hash.new(0)
351
+ findings.each { |f| counts[f[:severity]] += 1 }
352
+
353
+ %w[critical high medium low info].each do |sev|
354
+ expected = counts[sev]
355
+ actual = summary[sev.to_sym]
356
+ unless actual == expected
357
+ raise ReporterError, "summary.#{sev} count (#{actual}) does not match findings (#{expected})"
358
+ end
359
+ end
360
+
361
+ unless summary[:suppressed] == suppressed.size
362
+ raise ReporterError,
363
+ "summary.suppressed (#{summary[:suppressed]}) does not match suppressed array size (#{suppressed.size})"
364
+ end
365
+
366
+ return if summary[:total] == findings.size
367
+
368
+ raise ReporterError, "summary.total (#{summary[:total]}) does not match findings count (#{findings.size})"
369
+ end
370
+
371
+ def validate_non_negative_integer!(value, field_name)
372
+ return if value.is_a?(Integer) && value >= 0
373
+
374
+ raise ReporterError, "#{field_name} must be a non-negative Integer, got: #{value.inspect}"
375
+ end
376
+
377
+ def validate_findings_array!(findings_array, context)
378
+ raise ReporterError, "#{context} must be an Array" unless findings_array.is_a?(Array)
379
+
380
+ findings_array.each_with_index do |finding, idx|
381
+ validate_finding_hash!(finding, context, idx)
382
+ end
383
+ end
384
+
385
+ # The field-by-field checks intentionally mirror Finding's public shape.
386
+ # rubocop:disable Metrics/AbcSize
387
+ def validate_finding_hash!(finding, context, idx)
388
+ raise ReporterError, "#{context}[#{idx}] must be a Hash" unless finding.is_a?(Hash)
389
+
390
+ required_keys = %i[rule_id title category severity confidence location symbol operation execution_context message
391
+ evidence remediation fingerprint]
392
+ actual_keys = finding.keys
393
+
394
+ missing = required_keys - actual_keys
395
+ raise ReporterError, "#{context}[#{idx}] missing required keys: #{missing.join(', ')}" unless missing.empty?
396
+
397
+ unknown = actual_keys - required_keys
398
+ raise ReporterError, "#{context}[#{idx}] has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
399
+
400
+ %i[rule_id message fingerprint].each do |key|
401
+ value = finding[key]
402
+ unless value.is_a?(String) && !value.empty?
403
+ raise ReporterError, "#{context}[#{idx}].#{key} must be non-empty string"
404
+ end
405
+ end
406
+
407
+ unless finding[:severity].is_a?(String) && ALLOWED_SEVERITIES.include?(finding[:severity])
408
+ raise ReporterError, "#{context}[#{idx}].severity must be known severity string"
409
+ end
410
+
411
+ unless finding[:confidence].is_a?(String) && ALLOWED_CONFIDENCES.include?(finding[:confidence])
412
+ raise ReporterError, "#{context}[#{idx}].confidence must be known confidence string"
413
+ end
414
+
415
+ %i[title category symbol operation execution_context remediation].each do |key|
416
+ value = finding[key]
417
+ next if value.nil?
418
+ next if json_safe?(value)
419
+
420
+ raise ReporterError, "#{context}[#{idx}].#{key} is not JSON-safe"
421
+ end
422
+
423
+ validate_location_hash!(finding[:location], context, idx)
424
+ validate_evidence_array!(finding[:evidence], context, idx)
425
+ end
426
+ # rubocop:enable Metrics/AbcSize
427
+
428
+ def validate_location_hash!(location, context, idx)
429
+ return if location.nil?
430
+
431
+ raise ReporterError, "#{context}[#{idx}].location must be a Hash or nil" unless location.is_a?(Hash)
432
+
433
+ required_keys = %i[path line column]
434
+ actual_keys = location.keys
435
+
436
+ missing = required_keys - actual_keys
437
+ raise ReporterError, "#{context}[#{idx}].location missing required keys: #{missing.join(', ')}" unless missing.empty?
438
+
439
+ unknown = actual_keys - required_keys
440
+ raise ReporterError, "#{context}[#{idx}].location has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
441
+
442
+ path = location[:path]
443
+ line = location[:line]
444
+ column = location[:column]
445
+
446
+ unless path.is_a?(String) && !path.empty?
447
+ raise ReporterError, "#{context}[#{idx}].location.path must be non-empty string"
448
+ end
449
+
450
+ unless line.nil? || (line.is_a?(Integer) && line.positive?)
451
+ raise ReporterError, "#{context}[#{idx}].location.line must be positive Integer or nil"
452
+ end
453
+
454
+ return if column.nil? || (column.is_a?(Integer) && column >= 0)
455
+
456
+ raise ReporterError, "#{context}[#{idx}].location.column must be non-negative Integer or nil"
457
+ end
458
+
459
+ def validate_evidence_array!(evidence_array, context, idx)
460
+ unless evidence_array.is_a?(Array) && !evidence_array.empty?
461
+ raise ReporterError, "#{context}[#{idx}].evidence must be non-empty Array"
462
+ end
463
+
464
+ evidence_array.each_with_index do |evidence, evidence_idx|
465
+ validate_evidence_hash!(evidence, context, idx, evidence_idx)
466
+ end
467
+ end
468
+
469
+ def validate_evidence_hash!(evidence, context, idx, evidence_idx)
470
+ unless evidence.is_a?(Hash)
471
+ message = "#{context}[#{idx}].evidence[#{evidence_idx}] must be a Hash"
472
+ raise ReporterError, message
473
+ end
474
+
475
+ required_keys = %i[source message details]
476
+ actual_keys = evidence.keys
477
+
478
+ missing = required_keys - actual_keys
479
+ unless missing.empty?
480
+ raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}] missing required keys: #{missing.join(', ')}"
481
+ end
482
+
483
+ unknown = actual_keys - required_keys
484
+ unless unknown.empty?
485
+ raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}] has unknown keys: #{unknown.join(', ')}"
486
+ end
487
+
488
+ source = evidence[:source]
489
+ message = evidence[:message]
490
+
491
+ unless source.is_a?(String) && !source.empty?
492
+ raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].source must be non-empty string"
493
+ end
494
+
495
+ unless message.is_a?(String) && !message.empty?
496
+ raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].message must be non-empty string"
497
+ end
498
+
499
+ details = evidence[:details]
500
+ return if details.nil? || json_safe_recursive?(details)
501
+
502
+ raise ReporterError, "#{context}[#{idx}].evidence[#{evidence_idx}].details is not JSON-safe"
503
+ end
504
+
505
+ def validate_parse_errors!(parse_errors)
506
+ raise ReporterError, 'parse_errors must be an Array' unless parse_errors.is_a?(Array)
507
+
508
+ parse_errors.each_with_index do |error, idx|
509
+ validate_parse_error_hash!(error, idx)
510
+ end
511
+ end
512
+
513
+ def validate_parse_error_hash!(error, idx)
514
+ raise ReporterError, "parse_errors[#{idx}] must be a Hash" unless error.is_a?(Hash)
515
+
516
+ required_keys = %i[path message line]
517
+ actual_keys = error.keys
518
+
519
+ missing = required_keys - actual_keys
520
+ raise ReporterError, "parse_errors[#{idx}] missing required keys: #{missing.join(', ')}" unless missing.empty?
521
+
522
+ unknown = actual_keys - required_keys
523
+ raise ReporterError, "parse_errors[#{idx}] has unknown keys: #{unknown.join(', ')}" unless unknown.empty?
524
+
525
+ path = error[:path]
526
+ message = error[:message]
527
+ line = error[:line]
528
+
529
+ raise ReporterError, "parse_errors[#{idx}].path must be non-empty string" unless path.is_a?(String) && !path.empty?
530
+
531
+ unless message.is_a?(String) && !message.empty?
532
+ raise ReporterError, "parse_errors[#{idx}].message must be non-empty string"
533
+ end
534
+
535
+ return if line.nil? || (line.is_a?(Integer) && line.positive?)
536
+
537
+ raise ReporterError, "parse_errors[#{idx}].line must be positive Integer or nil"
538
+ end
539
+
540
+ def build_summary(findings, suppressed)
541
+ counts = {
542
+ critical: 0,
543
+ high: 0,
544
+ medium: 0,
545
+ low: 0,
546
+ info: 0
547
+ }
548
+
549
+ findings.each do |finding|
550
+ severity = finding[:severity].to_sym
551
+ counts[severity] += 1 if counts.key?(severity)
552
+ end
553
+
554
+ counts[:suppressed] = suppressed.size
555
+ counts[:total] = findings.size
556
+
557
+ counts.freeze
558
+ end
559
+
560
+ def sort_findings(findings)
561
+ findings.sort_by do |f|
562
+ [
563
+ Severity.index(f[:severity].to_sym),
564
+ f[:rule_id].to_s,
565
+ f[:location] ? f[:location][:path].to_s : '',
566
+ f[:location] ? (f[:location][:line] || Float::INFINITY) : Float::INFINITY,
567
+ f[:fingerprint].to_s
568
+ ]
569
+ end
570
+ end
571
+ end
572
+ # rubocop:enable Metrics/ModuleLength
573
+ end
574
+ end