1use std::path::Path;
4
5use fallow_output::{
6 CodeClimateIssue, CodeClimateIssueInput, CodeClimateSeverity, ComplexityViolation,
7 CoverageIntelligenceFinding, CoverageIntelligenceRecommendation, CoverageIntelligenceVerdict,
8 ExceededThreshold, FindingSeverity, HealthReport, HealthSummary, RuntimeCoverageFinding,
9 RuntimeCoverageVerdict, StylingFinding, StylingFindingSeverity, UntestedExportFinding,
10 UntestedFileFinding, build_codeclimate_issue, codeclimate_fingerprint_hash, normalize_uri,
11};
12use fallow_types::output_dead_code::EffectiveSeverity;
13
14struct HealthCodeClimateContext<'a> {
15 root: &'a Path,
16 summary: &'a HealthSummary,
20}
21
22impl HealthCodeClimateContext<'_> {
23 fn complexity_issue(&self, finding: &ComplexityViolation) -> CodeClimateIssue {
24 let path = codeclimate_path(&finding.path, self.root);
25 let check_name = complexity_check_name(finding);
26 let line_str = finding.line.to_string();
27 let fp = codeclimate_fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
28 build_codeclimate_issue(CodeClimateIssueInput {
29 check_name,
30 description: &self.complexity_description(finding),
31 severity: complexity_codeclimate_severity(finding),
32 category: "Complexity",
33 path: &path,
34 begin_line: Some(finding.line),
35 fingerprint: &fp,
36 })
37 }
38
39 fn styling_issue(&self, finding: &StylingFinding) -> CodeClimateIssue {
40 let path = codeclimate_path(Path::new(&finding.path), self.root);
41 let check_name = format!("fallow/{}", finding.code);
42 let description = format!("[{}] {}: {}", finding.code, finding.sub_kind, finding.value);
43 let line_str = finding.line.to_string();
44 let fp = codeclimate_fingerprint_hash(&[
45 &check_name,
46 &path,
47 &line_str,
48 &finding.sub_kind,
49 &finding.value,
50 ]);
51 build_codeclimate_issue(CodeClimateIssueInput {
52 check_name: &check_name,
53 description: &description,
54 severity: styling_finding_severity(finding.effective_severity),
55 category: "Style",
56 path: &path,
57 begin_line: Some(finding.line),
58 fingerprint: &fp,
59 })
60 }
61
62 fn complexity_description(&self, finding: &ComplexityViolation) -> String {
63 let thresholds = finding.resolved_thresholds(self.summary);
64 match finding.exceeded {
65 ExceededThreshold::Both => format!(
66 "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
67 finding.name,
68 finding.cyclomatic,
69 thresholds.max_cyclomatic,
70 finding.cognitive,
71 thresholds.max_cognitive
72 ),
73 ExceededThreshold::Cyclomatic => format!(
74 "'{}' has cyclomatic complexity {} (threshold: {})",
75 finding.name, finding.cyclomatic, thresholds.max_cyclomatic
76 ),
77 ExceededThreshold::Cognitive => format!(
78 "'{}' has cognitive complexity {} (threshold: {})",
79 finding.name, finding.cognitive, thresholds.max_cognitive
80 ),
81 ExceededThreshold::Crap
82 | ExceededThreshold::CyclomaticCrap
83 | ExceededThreshold::CognitiveCrap
84 | ExceededThreshold::All => {
85 let crap = finding.crap.unwrap_or(0.0);
86 let coverage = finding
87 .coverage_pct
88 .map(|pct| format!(", coverage {pct:.0}%"))
89 .unwrap_or_default();
90 format!(
91 "'{}' has CRAP score {crap:.1} (threshold: {:.1}, cyclomatic {}{coverage})",
92 finding.name, thresholds.max_crap, finding.cyclomatic,
93 )
94 }
95 }
96 }
97
98 fn runtime_coverage_issue(&self, finding: &RuntimeCoverageFinding) -> CodeClimateIssue {
99 let path = codeclimate_path(&finding.path, self.root);
100 let check_name = runtime_coverage_check_name(finding.verdict);
101 let invocations_hint = finding.invocations.map_or_else(
102 || "untracked".to_owned(),
103 |hits| format!("{hits} invocations"),
104 );
105 let description = format!(
106 "'{}' runtime coverage verdict: {} ({})",
107 finding.function,
108 finding.verdict.human_label(),
109 invocations_hint,
110 );
111 let fp = codeclimate_fingerprint_hash(&[
112 check_name,
113 &path,
114 &finding.line.to_string(),
115 &finding.function,
116 ]);
117 build_codeclimate_issue(CodeClimateIssueInput {
118 check_name,
119 description: &description,
120 severity: runtime_coverage_severity(finding.verdict),
121 category: "Bug Risk",
122 path: &path,
123 begin_line: Some(finding.line),
124 fingerprint: &fp,
125 })
126 }
127
128 fn coverage_intelligence_issue(
129 &self,
130 finding: &CoverageIntelligenceFinding,
131 ) -> Option<CodeClimateIssue> {
132 let severity = coverage_intelligence_severity(finding.verdict)?;
133 let path = codeclimate_path(&finding.path, self.root);
134 let check_name = coverage_intelligence_check_name(finding.recommendation);
135 let identity = finding.identity.as_deref().unwrap_or("code");
136 let description = format!(
137 "'{}' coverage intelligence verdict: {} ({})",
138 identity, finding.verdict, finding.recommendation,
139 );
140 let fp = codeclimate_fingerprint_hash(&[
141 check_name,
142 &path,
143 &finding.line.to_string(),
144 identity,
145 &finding.id,
146 ]);
147 Some(build_codeclimate_issue(CodeClimateIssueInput {
148 check_name,
149 description: &description,
150 severity,
151 category: "Bug Risk",
152 path: &path,
153 begin_line: Some(finding.line),
154 fingerprint: &fp,
155 }))
156 }
157
158 fn untested_file_issue(&self, item: &UntestedFileFinding) -> CodeClimateIssue {
159 let path = codeclimate_path(&item.file.path, self.root);
160 let description = format!(
161 "File is runtime-reachable but has no test dependency path ({} value export{})",
162 item.file.value_export_count,
163 if item.file.value_export_count == 1 {
164 ""
165 } else {
166 "s"
167 },
168 );
169 let fp = codeclimate_fingerprint_hash(&["fallow/untested-file", &path]);
170 build_codeclimate_issue(CodeClimateIssueInput {
171 check_name: "fallow/untested-file",
172 description: &description,
173 severity: CodeClimateSeverity::Minor,
174 category: "Coverage",
175 path: &path,
176 begin_line: None,
177 fingerprint: &fp,
178 })
179 }
180
181 fn untested_export_issue(&self, item: &UntestedExportFinding) -> CodeClimateIssue {
182 let path = codeclimate_path(&item.export.path, self.root);
183 let description = format!(
184 "Export '{}' is runtime-reachable but never referenced by test-reachable modules",
185 item.export.export_name
186 );
187 let line_str = item.export.line.to_string();
188 let fp = codeclimate_fingerprint_hash(&[
189 "fallow/untested-export",
190 &path,
191 &line_str,
192 &item.export.export_name,
193 ]);
194 build_codeclimate_issue(CodeClimateIssueInput {
195 check_name: "fallow/untested-export",
196 description: &description,
197 severity: CodeClimateSeverity::Minor,
198 category: "Coverage",
199 path: &path,
200 begin_line: Some(item.export.line),
201 fingerprint: &fp,
202 })
203 }
204}
205
206#[must_use]
208pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> Vec<CodeClimateIssue> {
209 let mut issues = Vec::new();
210 let ctx = HealthCodeClimateContext {
211 root,
212 summary: &report.summary,
213 };
214
215 for finding in &report.findings {
216 issues.push(ctx.complexity_issue(finding));
217 }
218 for finding in &report.styling_findings {
219 issues.push(ctx.styling_issue(finding));
220 }
221
222 if let Some(ref production) = report.runtime_coverage {
223 for finding in &production.findings {
224 issues.push(ctx.runtime_coverage_issue(finding));
225 }
226 }
227
228 if let Some(ref intelligence) = report.coverage_intelligence {
229 for finding in &intelligence.findings {
230 if let Some(issue) = ctx.coverage_intelligence_issue(finding) {
231 issues.push(issue);
232 }
233 }
234 }
235
236 if let Some(ref gaps) = report.coverage_gaps {
237 for item in &gaps.files {
238 issues.push(ctx.untested_file_issue(item));
239 }
240
241 for item in &gaps.exports {
242 issues.push(ctx.untested_export_issue(item));
243 }
244 }
245
246 issues
247}
248
249fn codeclimate_path(path: &Path, root: &Path) -> String {
250 normalize_uri(
251 &path
252 .strip_prefix(root)
253 .unwrap_or(path)
254 .display()
255 .to_string(),
256 )
257}
258
259const fn coverage_intelligence_check_name(
260 recommendation: CoverageIntelligenceRecommendation,
261) -> &'static str {
262 match recommendation {
263 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge => {
264 "fallow/coverage-intelligence-risky-change"
265 }
266 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner => {
267 "fallow/coverage-intelligence-delete"
268 }
269 CoverageIntelligenceRecommendation::ReviewBeforeChanging => {
270 "fallow/coverage-intelligence-review"
271 }
272 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior => {
273 "fallow/coverage-intelligence-refactor"
274 }
275 }
276}
277
278const fn complexity_check_name(finding: &ComplexityViolation) -> &'static str {
279 match finding.exceeded {
280 ExceededThreshold::Both => "fallow/high-complexity",
281 ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
282 ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
283 ExceededThreshold::Crap
284 | ExceededThreshold::CyclomaticCrap
285 | ExceededThreshold::CognitiveCrap
286 | ExceededThreshold::All => "fallow/high-crap-score",
287 }
288}
289
290const fn complexity_codeclimate_severity(finding: &ComplexityViolation) -> CodeClimateSeverity {
297 match finding.effective_severity {
298 Some(EffectiveSeverity::Error) => CodeClimateSeverity::Major,
299 Some(EffectiveSeverity::Warn) => CodeClimateSeverity::Minor,
300 None => health_finding_severity(finding.severity),
301 }
302}
303
304const fn health_finding_severity(severity: FindingSeverity) -> CodeClimateSeverity {
305 match severity {
306 FindingSeverity::Critical => CodeClimateSeverity::Critical,
307 FindingSeverity::High => CodeClimateSeverity::Major,
308 FindingSeverity::Moderate => CodeClimateSeverity::Minor,
309 }
310}
311
312const fn styling_finding_severity(severity: StylingFindingSeverity) -> CodeClimateSeverity {
313 match severity {
314 StylingFindingSeverity::Error => CodeClimateSeverity::Major,
315 StylingFindingSeverity::Warn => CodeClimateSeverity::Minor,
316 }
317}
318
319const fn runtime_coverage_check_name(verdict: RuntimeCoverageVerdict) -> &'static str {
320 match verdict {
321 RuntimeCoverageVerdict::SafeToDelete => "fallow/runtime-safe-to-delete",
322 RuntimeCoverageVerdict::ReviewRequired => "fallow/runtime-review-required",
323 RuntimeCoverageVerdict::LowTraffic => "fallow/runtime-low-traffic",
324 RuntimeCoverageVerdict::CoverageUnavailable => "fallow/runtime-coverage-unavailable",
325 RuntimeCoverageVerdict::Active | RuntimeCoverageVerdict::Unknown => {
326 "fallow/runtime-coverage"
327 }
328 }
329}
330
331const fn runtime_coverage_severity(verdict: RuntimeCoverageVerdict) -> CodeClimateSeverity {
332 match verdict {
333 RuntimeCoverageVerdict::SafeToDelete => CodeClimateSeverity::Critical,
334 RuntimeCoverageVerdict::ReviewRequired => CodeClimateSeverity::Major,
335 _ => CodeClimateSeverity::Minor,
336 }
337}
338
339const fn coverage_intelligence_severity(
340 verdict: CoverageIntelligenceVerdict,
341) -> Option<CodeClimateSeverity> {
342 match verdict {
343 CoverageIntelligenceVerdict::RiskyChangeDetected
344 | CoverageIntelligenceVerdict::HighConfidenceDelete => Some(CodeClimateSeverity::Major),
345 CoverageIntelligenceVerdict::ReviewRequired
346 | CoverageIntelligenceVerdict::RefactorCarefully => Some(CodeClimateSeverity::Minor),
347 CoverageIntelligenceVerdict::Clean | CoverageIntelligenceVerdict::Unknown => None,
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use std::path::{Path, PathBuf};
354
355 use fallow_output::{
356 ComplexityViolation, ExceededThreshold, FindingSeverity, HealthReport, HealthSummary,
357 StylingFinding, StylingFindingSeverity,
358 };
359
360 use super::*;
361
362 #[test]
363 fn health_codeclimate_uses_relative_normalized_paths() {
364 let report = HealthReport {
365 summary: HealthSummary {
366 max_cyclomatic_threshold: 10,
367 max_cognitive_threshold: 8,
368 max_crap_threshold: 30.0,
369 ..HealthSummary::default()
370 },
371 findings: vec![
372 ComplexityViolation {
373 path: PathBuf::from("/root/app/[id]/page.tsx"),
374 name: "render".to_string(),
375 line: 7,
376 col: 0,
377 cyclomatic: 12,
378 cognitive: 9,
379 line_count: 20,
380 param_count: 1,
381 react_hook_count: 0,
382 react_jsx_max_depth: 0,
383 react_prop_count: 0,
384 react_hook_profile: None,
385 exceeded: ExceededThreshold::Both,
386 effective_severity: None,
387 severity: FindingSeverity::High,
388 mutation: None,
389 coverage_pct: None,
390 crap: None,
391 coverage_tier: None,
392 coverage_source: None,
393 inherited_from: None,
394 component_rollup: None,
395 contributions: Vec::new(),
396 effective_thresholds: None,
397 threshold_source: None,
398 }
399 .into(),
400 ],
401 ..HealthReport::default()
402 };
403
404 let issues = build_health_codeclimate(&report, Path::new("/root"));
405
406 assert_eq!(issues.len(), 1);
407 let issue = &issues[0];
408 assert_eq!(issue.check_name, "fallow/high-complexity");
409 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
410 assert_eq!(issue.location.lines.begin, 7);
411 assert_eq!(issue.severity, CodeClimateSeverity::Major);
412 }
413
414 #[test]
415 fn health_codeclimate_includes_styling_findings() {
416 let report = HealthReport {
417 styling_findings: vec![StylingFinding {
418 code: "css-selector-complexity".to_string(),
419 sub_kind: "high-specificity".to_string(),
420 path: "src/styles.css".to_string(),
421 line: 4,
422 value: "#app .card .title".to_string(),
423 effective_severity: StylingFindingSeverity::Error,
424 blast_radius: None,
425 confidence: None,
426 agent_disposition: None,
427 nearest_token: None,
428 fix_hint: None,
429 actions: Vec::new(),
430 }],
431 ..HealthReport::default()
432 };
433
434 let issues = build_health_codeclimate(&report, Path::new("/root"));
435
436 assert_eq!(issues.len(), 1);
437 let issue = &issues[0];
438 assert_eq!(issue.check_name, "fallow/css-selector-complexity");
439 assert_eq!(issue.location.path, "src/styles.css");
440 assert_eq!(issue.location.lines.begin, 4);
441 assert_eq!(issue.severity, CodeClimateSeverity::Major);
442 }
443
444 fn crap_violation(
445 effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
446 ) -> ComplexityViolation {
447 ComplexityViolation {
448 path: PathBuf::from("/root/src/Board.astro"),
449 name: "<template>".to_string(),
450 line: 6,
451 col: 3,
452 cyclomatic: 11,
453 cognitive: 4,
454 line_count: 20,
455 param_count: 0,
456 react_hook_count: 0,
457 react_jsx_max_depth: 0,
458 react_prop_count: 0,
459 react_hook_profile: None,
460 exceeded: ExceededThreshold::Crap,
461 effective_severity: None,
462 severity: FindingSeverity::Critical,
463 mutation: None,
464 coverage_pct: None,
465 crap: Some(132.0),
466 coverage_tier: None,
467 coverage_source: None,
468 inherited_from: None,
469 component_rollup: None,
470 contributions: Vec::new(),
471 threshold_source: effective_thresholds
472 .map(|_| fallow_output::ThresholdSource::Override),
473 effective_thresholds,
474 }
475 }
476
477 fn crap_report(
478 effective_thresholds: Option<fallow_output::HealthEffectiveThresholds>,
479 ) -> HealthReport {
480 HealthReport {
481 summary: HealthSummary {
482 max_crap_threshold: 30.0,
483 ..HealthSummary::default()
484 },
485 findings: vec![crap_violation(effective_thresholds).into()],
486 ..HealthReport::default()
487 }
488 }
489
490 #[test]
494 fn codeclimate_description_uses_the_override_ceiling_not_the_global_one() {
495 let report = crap_report(Some(fallow_output::HealthEffectiveThresholds {
496 max_cyclomatic: 20,
497 max_cognitive: 15,
498 max_crap: 100.0,
499 max_unit_size: 60,
500 }));
501
502 let issues = build_health_codeclimate(&report, Path::new("/root"));
503
504 assert_eq!(issues.len(), 1);
505 assert!(
506 issues[0].description.contains("threshold: 100.0"),
507 "{}",
508 issues[0].description
509 );
510 assert!(
511 !issues[0].description.contains("threshold: 30.0"),
512 "{}",
513 issues[0].description
514 );
515 }
516
517 #[test]
518 fn codeclimate_description_falls_back_to_the_global_ceiling() {
519 let issues = build_health_codeclimate(&crap_report(None), Path::new("/root"));
520
521 assert_eq!(issues.len(), 1);
522 assert!(
523 issues[0].description.contains("threshold: 30.0"),
524 "{}",
525 issues[0].description
526 );
527 }
528
529 #[test]
530 fn health_severities_reach_serialized_codeclimate_issues() {
531 for (severity, expected) in [
532 (FindingSeverity::Critical, "critical"),
533 (FindingSeverity::High, "major"),
534 (FindingSeverity::Moderate, "minor"),
535 ] {
536 let mut violation = crap_violation(None);
537 violation.severity = severity;
538 let report = HealthReport {
539 findings: vec![violation.into()],
540 ..HealthReport::default()
541 };
542 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
543 &report,
544 Path::new("/root"),
545 ));
546 let issues = issues
547 .as_array()
548 .expect("CodeClimate output must be an array");
549 assert_eq!(issues.len(), 1);
550 assert_eq!(issues[0]["severity"], expected);
551 assert_eq!(issues[0]["check_name"], "fallow/high-crap-score");
552 }
553 }
554
555 #[test]
556 fn runtime_verdicts_reach_serialized_codeclimate_issues() {
557 use fallow_output::{
558 RuntimeCoverageConfidence, RuntimeCoverageEvidence, RuntimeCoverageReport,
559 };
560
561 for (verdict, check_name, severity) in [
562 (
563 RuntimeCoverageVerdict::SafeToDelete,
564 "fallow/runtime-safe-to-delete",
565 "critical",
566 ),
567 (
568 RuntimeCoverageVerdict::ReviewRequired,
569 "fallow/runtime-review-required",
570 "major",
571 ),
572 (
573 RuntimeCoverageVerdict::LowTraffic,
574 "fallow/runtime-low-traffic",
575 "minor",
576 ),
577 (
578 RuntimeCoverageVerdict::CoverageUnavailable,
579 "fallow/runtime-coverage-unavailable",
580 "minor",
581 ),
582 (
583 RuntimeCoverageVerdict::Active,
584 "fallow/runtime-coverage",
585 "minor",
586 ),
587 (
588 RuntimeCoverageVerdict::Unknown,
589 "fallow/runtime-coverage",
590 "minor",
591 ),
592 ] {
593 let report = HealthReport {
594 runtime_coverage: Some(RuntimeCoverageReport {
595 findings: vec![RuntimeCoverageFinding {
596 id: "runtime-fixture".to_string(),
597 stable_id: None,
598 source_hash: None,
599 path: PathBuf::from("/root/src/legacy.ts"),
600 function: "legacyHelper".to_string(),
601 line: 12,
602 verdict,
603 invocations: Some(0),
604 confidence: RuntimeCoverageConfidence::High,
605 evidence: RuntimeCoverageEvidence {
606 static_status: "unused".to_string(),
607 test_coverage: "not_covered".to_string(),
608 test_only_reference: None,
609 v8_tracking: "tracked".to_string(),
610 untracked_reason: None,
611 observation_days: 30,
612 deployments_observed: 3,
613 },
614 actions: Vec::new(),
615 discriminators: None,
616 }],
617 ..RuntimeCoverageReport::default()
618 }),
619 ..HealthReport::default()
620 };
621 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
622 &report,
623 Path::new("/root"),
624 ));
625 let issues = issues
626 .as_array()
627 .expect("CodeClimate output must be an array");
628 assert_eq!(issues.len(), 1, "{verdict:?}");
629 assert_eq!(issues[0]["check_name"], check_name);
630 assert_eq!(issues[0]["severity"], severity);
631 assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
632 assert_eq!(issues[0]["location"]["lines"]["begin"], 12);
633 }
634 }
635
636 #[test]
637 fn coverage_intelligence_verdicts_and_recommendations_reach_serialized_issues() {
638 use fallow_output::{
639 CoverageIntelligenceConfidence, CoverageIntelligenceEvidence,
640 CoverageIntelligenceReport,
641 };
642
643 let recommendations = [
644 (
645 CoverageIntelligenceRecommendation::AddTestOrSplitBeforeMerge,
646 "fallow/coverage-intelligence-risky-change",
647 ),
648 (
649 CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
650 "fallow/coverage-intelligence-delete",
651 ),
652 (
653 CoverageIntelligenceRecommendation::ReviewBeforeChanging,
654 "fallow/coverage-intelligence-review",
655 ),
656 (
657 CoverageIntelligenceRecommendation::RefactorCarefullyKeepBehavior,
658 "fallow/coverage-intelligence-refactor",
659 ),
660 ];
661 for (verdict, severity) in [
662 (
663 CoverageIntelligenceVerdict::RiskyChangeDetected,
664 Some("major"),
665 ),
666 (
667 CoverageIntelligenceVerdict::HighConfidenceDelete,
668 Some("major"),
669 ),
670 (CoverageIntelligenceVerdict::ReviewRequired, Some("minor")),
671 (
672 CoverageIntelligenceVerdict::RefactorCarefully,
673 Some("minor"),
674 ),
675 (CoverageIntelligenceVerdict::Clean, None),
676 (CoverageIntelligenceVerdict::Unknown, None),
677 ] {
678 for (recommendation, check_name) in recommendations {
679 let report = HealthReport {
680 coverage_intelligence: Some(CoverageIntelligenceReport {
681 schema_version: fallow_output::CoverageIntelligenceSchemaVersion::default(),
682 verdict,
683 summary: fallow_output::CoverageIntelligenceSummary::default(),
684 findings: vec![CoverageIntelligenceFinding {
685 id: "coverage-fixture".to_string(),
686 path: PathBuf::from("/root/src/legacy.ts"),
687 identity: Some("legacyHelper".to_string()),
688 line: 12,
689 verdict,
690 signals: Vec::new(),
691 recommendation,
692 confidence: CoverageIntelligenceConfidence::High,
693 related_ids: Vec::new(),
694 evidence: CoverageIntelligenceEvidence::default(),
695 actions: Vec::new(),
696 }],
697 }),
698 ..HealthReport::default()
699 };
700 let issues = fallow_output::codeclimate_issues_to_value(&build_health_codeclimate(
701 &report,
702 Path::new("/root"),
703 ));
704 let issues = issues
705 .as_array()
706 .expect("CodeClimate output must be an array");
707 if let Some(severity) = severity {
708 assert_eq!(issues.len(), 1, "{verdict:?}");
709 assert_eq!(issues[0]["check_name"], check_name);
710 assert_eq!(issues[0]["severity"], severity);
711 assert_eq!(issues[0]["location"]["path"], "src/legacy.ts");
712 } else {
713 assert!(issues.is_empty(), "{verdict:?}");
714 }
715 }
716 }
717 }
718}