1use std::path::Path;
17use std::process::ExitCode;
18
19use fallow_types::output_dead_code::caveat_labels_for_tokens;
20use serde_json::Value;
21
22use super::github::{
23 Annotation, AnnotationLevel, PackageManager, RenderOptions, arr, b, budget_notice, fmt_num,
24 num, one_based_col, render_annotation, resolve_render_options, s, sort_annotations, u,
25};
26use crate::report::sink::outln;
27
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum EnvelopeKind {
32 DeadCode,
33 Architecture,
36 Dupes,
37 Health,
38 Audit,
39 Combined,
40 Security,
41 Fix,
45}
46
47impl EnvelopeKind {
48 pub const fn is_dead_code_family(self) -> bool {
51 matches!(self, Self::DeadCode | Self::Architecture)
52 }
53}
54
55impl From<fallow_output::CheckEnvelope> for EnvelopeKind {
56 fn from(envelope: fallow_output::CheckEnvelope) -> Self {
57 match envelope {
58 fallow_output::CheckEnvelope::DeadCode => Self::DeadCode,
59 fallow_output::CheckEnvelope::Architecture => Self::Architecture,
60 }
61 }
62}
63
64pub(crate) fn print_annotations(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
67 let options = resolve_render_options(root);
68 let rendered = render_annotations(kind, envelope, &options);
69 if let Some(line) = crate::report::gate_outcome_text::annotation_line(envelope) {
73 outln!("{line}");
74 }
75 if let Some(line) = crate::report::request_outcome_text::annotation_line(envelope) {
76 outln!("{line}");
77 }
78 if !rendered.is_empty() {
79 outln!("{rendered}");
80 }
81 ExitCode::SUCCESS
82}
83
84#[must_use]
88pub fn render_annotations(kind: EnvelopeKind, envelope: &Value, options: &RenderOptions) -> String {
89 let mut annotations = collect_annotations(kind, envelope, options.pm);
90 sort_annotations(&mut annotations);
91 if kind == EnvelopeKind::Audit && envelope["attribution"]["gate"] == "new-only" {
92 annotations
93 .sort_by_key(|annotation| (annotation.level, annotation.introduced != Some(true)));
94 }
95 let mut lines: Vec<String> = Vec::with_capacity(annotations.len() + 1);
96 for annotation in &mut annotations {
97 annotation.path = options.rebase.apply(&annotation.path);
98 lines.push(render_annotation(annotation));
99 }
100 if let Some(notice) = budget_notice(annotations.len()) {
101 lines.push(notice);
102 }
103 lines.join("\n")
104}
105
106pub(crate) fn collect_annotations(
107 kind: EnvelopeKind,
108 envelope: &Value,
109 pm: PackageManager,
110) -> Vec<Annotation> {
111 let mut out = Vec::new();
112 match kind {
113 EnvelopeKind::DeadCode | EnvelopeKind::Architecture => {
114 collect_check(envelope, pm, &mut out);
115 }
116 EnvelopeKind::Dupes => collect_dupes(envelope, &mut out),
117 EnvelopeKind::Health => collect_health(envelope, &mut out),
118 EnvelopeKind::Security => collect_security(envelope, &mut out),
119 EnvelopeKind::Audit => {
120 collect_section(envelope, "dead_code", pm, &mut out, collect_check);
121 collect_value_section(envelope, "complexity", &mut out, collect_health);
122 collect_value_section(envelope, "complexity", &mut out, collect_audit_styling);
123 collect_value_section(envelope, "duplication", &mut out, collect_dupes);
124 collect_value_section(envelope, "diff_risk", &mut out, collect_diff_risk);
125 }
126 EnvelopeKind::Combined => {
127 collect_section(envelope, "check", pm, &mut out, collect_check);
128 collect_value_section(envelope, "health", &mut out, collect_health);
129 collect_value_section(envelope, "dupes", &mut out, collect_dupes);
130 }
131 EnvelopeKind::Fix => {}
135 }
136 out
137}
138
139fn collect_section(
140 envelope: &Value,
141 key: &str,
142 pm: PackageManager,
143 out: &mut Vec<Annotation>,
144 collect: fn(&Value, PackageManager, &mut Vec<Annotation>),
145) {
146 if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
147 collect(section, pm, out);
148 }
149}
150
151fn collect_value_section(
152 envelope: &Value,
153 key: &str,
154 out: &mut Vec<Annotation>,
155 collect: fn(&Value, &mut Vec<Annotation>),
156) {
157 if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
158 collect(section, out);
159 }
160}
161
162#[derive(Clone, Copy, Default)]
164struct Anchor {
165 line: Option<u64>,
166 col: Option<u64>,
167 introduced: Option<bool>,
168}
169
170impl Anchor {
171 fn with_introduced(mut self, finding: &Value) -> Self {
172 self.introduced = finding.get("introduced").and_then(Value::as_bool);
173 self
174 }
175
176 fn line_col(item: &Value) -> Self {
178 Self {
179 line: Some(u(item, "line")),
180 col: Some(one_based_col(u(item, "col"))),
181 ..Self::default().with_introduced(item)
182 }
183 }
184
185 fn line_only(item: &Value) -> Self {
187 Self {
188 line: Some(u(item, "line")),
189 col: None,
190 ..Self::default().with_introduced(item)
191 }
192 }
193
194 fn gated_line_col(item: &Value) -> Self {
196 if u(item, "line") > 0 {
197 Self::line_col(item)
198 } else {
199 Self::default().with_introduced(item)
200 }
201 }
202
203 fn gated_line(item: &Value) -> Self {
205 let line = u(item, "line");
206 Self {
207 line: (line > 0).then_some(line),
208 col: None,
209 ..Self::default().with_introduced(item)
210 }
211 }
212}
213
214fn push(
215 out: &mut Vec<Annotation>,
216 level: AnnotationLevel,
217 path: &str,
218 anchor: Anchor,
219 title: String,
220 message: String,
221) {
222 out.push(Annotation {
223 level,
224 path: path.to_owned(),
225 line: anchor.line,
226 end_line: None,
227 col: anchor.col,
228 title,
229 message,
230 introduced: anchor.introduced,
231 });
232}
233
234fn gate_level(item: &Value, fallback: AnnotationLevel) -> AnnotationLevel {
241 match item.get("effective_severity").and_then(Value::as_str) {
242 Some("error") => AnnotationLevel::Error,
243 Some("warn") => AnnotationLevel::Warning,
244 _ => fallback,
245 }
246}
247
248fn push_each(
252 out: &mut Vec<Annotation>,
253 env: &Value,
254 key: &str,
255 title: &str,
256 anchor: fn(&Value) -> Anchor,
257 message: impl Fn(&Value) -> String,
258) {
259 for item in arr(env, key) {
260 push(
261 out,
262 gate_level(item, AnnotationLevel::Warning),
263 s(item, "path"),
264 anchor(item),
265 title.to_owned(),
266 message(item),
267 );
268 }
269}
270
271fn no_anchor(item: &Value) -> Anchor {
272 Anchor::default().with_introduced(item)
273}
274
275fn joined_strs(item: &Value, key: &str, separator: &str) -> String {
276 arr(item, key)
277 .filter_map(Value::as_str)
278 .collect::<Vec<_>>()
279 .join(separator)
280}
281
282fn caveat_note(item: &Value) -> String {
292 caveat_labels_for_tokens(arr(item, "reachability_caveats").filter_map(Value::as_str)).map_or_else(
293 String::new,
294 |labels| {
295 format!(
296 "\n\nCaveat: {labels}. A file this run did not fully read can hide the reference that would credit this, so verify before removing."
297 )
298 },
299 )
300}
301
302fn workspace_context(item: &Value) -> String {
303 let workspaces = joined_strs(item, "used_in_workspaces", ", ");
304 if !workspaces.is_empty() {
305 return format!("\n\nImported in other workspaces: {workspaces}");
306 }
307 let declaring = joined_strs(item, "declared_and_imported_in", ", ");
308 if declaring.is_empty() {
309 String::new()
310 } else {
311 format!("\n\nDeclared and imported in other workspaces: {declaring}")
312 }
313}
314
315fn dependency_action(item: &Value, pm: PackageManager) -> String {
316 if arr(item, "used_in_workspaces").next().is_some() {
317 "Move this dependency to the consuming workspace package.json.".to_owned()
318 } else {
319 format!("Run: {}", pm.remove_command(s(item, "package_name")))
320 }
321}
322
323fn unused_dependency_message(item: &Value, section: &str, pm: PackageManager) -> String {
324 format!(
325 "Package '{}' is listed in {section} but never imported by this package.{}\n\n{}{}",
326 s(item, "package_name"),
327 workspace_context(item),
328 dependency_action(item, pm),
329 caveat_note(item),
330 )
331}
332
333fn collect_check(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
334 collect_check_files_and_exports(env, out);
335 collect_check_dependencies(env, pm, out);
336 collect_check_members(env, out);
337 collect_check_graph(env, out);
338 collect_check_boundaries(env, out);
339 collect_check_frameworks(env, out);
340 collect_check_components(env, out);
341 collect_check_suppressions(env, out);
342 collect_check_catalog(env, out);
343 collect_prop_drilling(env, out);
344}
345
346fn collect_prop_drilling(env: &Value, out: &mut Vec<Annotation>) {
347 for item in arr(env, "prop_drilling_chains") {
348 if s(item, "effective_severity") != "error" {
349 continue;
350 }
351 let Some(source) = arr(item, "hops").next() else {
352 continue;
353 };
354 push(
355 out,
356 AnnotationLevel::Error,
357 s(source, "file"),
358 Anchor {
359 line: Some(u(source, "line")),
360 col: None,
361 ..no_anchor(item)
362 },
363 "Prop drilling".to_owned(),
364 format!(
365 "Prop '{}' is forwarded unchanged through {} components. Review composition, colocation or context, or suppress the source prop declaration.",
366 s(item, "prop"),
367 u(item, "depth")
368 ),
369 );
370 }
371}
372
373fn collect_check_files_and_exports(env: &Value, out: &mut Vec<Annotation>) {
374 push_each(out, env, "unused_files", "Unused file", no_anchor, |it| {
375 format!(
376 "This file is not imported by any other module and unreachable from entry points.\nConsider removing it or importing it where needed.{}",
377 caveat_note(it),
378 )
379 });
380 push_each(
381 out,
382 env,
383 "unused_exports",
384 "Unused export",
385 Anchor::line_col,
386 |it| {
387 format!(
388 "{} {} '{}' is never imported by other modules.\n\nIf this export is part of a public API, consider adding it to the entry configuration.\nOtherwise, remove the export keyword or delete the declaration.{}",
389 if b(it, "is_re_export") {
390 "Re-exported"
391 } else {
392 "Exported"
393 },
394 if b(it, "is_type_only") {
395 "type"
396 } else {
397 "value"
398 },
399 s(it, "export_name"),
400 caveat_note(it),
401 )
402 },
403 );
404 push_each(
405 out,
406 env,
407 "unused_types",
408 "Unused type",
409 Anchor::line_col,
410 |it| {
411 format!(
412 "{} type '{}' is never imported by other modules.\n\nIf only used internally, remove the export keyword.{}",
413 if b(it, "is_re_export") {
414 "Re-exported"
415 } else {
416 "Exported"
417 },
418 s(it, "export_name"),
419 caveat_note(it),
420 )
421 },
422 );
423 push_each(
424 out,
425 env,
426 "private_type_leaks",
427 "Private type leak",
428 Anchor::line_col,
429 |it| {
430 format!(
431 "Export '{}' references private type '{}'.\n\nExport the referenced type or remove it from the public signature.",
432 s(it, "export_name"),
433 s(it, "type_name"),
434 )
435 },
436 );
437 push_each(
438 out,
439 env,
440 "deprecated_exports_in_use",
441 "Deprecated export in use",
442 Anchor::line_col,
443 deprecated_export_message,
444 );
445}
446
447fn deprecated_export_message(item: &Value) -> String {
448 let count = u(item, "consumer_count");
449 let reason = item
450 .get("deprecated_reason")
451 .and_then(Value::as_str)
452 .map_or_else(String::new, |reason| {
453 format!("\n\nDeprecation message: {reason}")
454 });
455 format!(
456 "Deprecated export '{}' is still used by {count} consumer{}.{reason}\n\nMove the consumers to the replacement, then remove the export.",
457 s(item, "export_name"),
458 if count == 1 { "" } else { "s" },
459 )
460}
461
462fn collect_check_dependencies(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
463 push_each(
464 out,
465 env,
466 "unused_dependencies",
467 "Unused dependency",
468 Anchor::gated_line,
469 |it| unused_dependency_message(it, "dependencies", pm),
470 );
471 push_each(
472 out,
473 env,
474 "unused_dev_dependencies",
475 "Unused devDependency",
476 Anchor::gated_line,
477 |it| unused_dependency_message(it, "devDependencies", pm),
478 );
479 push_each(
480 out,
481 env,
482 "unused_optional_dependencies",
483 "Unused optionalDependency",
484 Anchor::gated_line,
485 |it| unused_dependency_message(it, "optionalDependencies", pm),
486 );
487 for dependency in arr(env, "unlisted_dependencies") {
488 let package = s(dependency, "package_name");
489 for site in arr(dependency, "imported_from") {
490 push(
491 out,
492 gate_level(dependency, AnnotationLevel::Warning),
493 s(site, "path"),
494 Anchor::line_col(site).with_introduced(dependency),
495 "Unlisted dependency".to_owned(),
496 format!(
497 "Package '{package}' is imported here but not listed in package.json.\n\nRun: {}",
498 pm.add_command(package),
499 ),
500 );
501 }
502 }
503 push_each(
504 out,
505 env,
506 "type_only_dependencies",
507 "Type-only dependency",
508 Anchor::gated_line,
509 |it| {
510 format!(
511 "Package '{}' is only used via type imports.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
512 s(it, "package_name"),
513 )
514 },
515 );
516 push_each(
517 out,
518 env,
519 "test_only_dependencies",
520 "Test-only dependency",
521 Anchor::gated_line,
522 |it| {
523 format!(
524 "Package '{}' is only imported from test or config files.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
525 s(it, "package_name"),
526 )
527 },
528 );
529 push_each(
530 out,
531 env,
532 "dev_dependencies_in_production",
533 "Dev dependency in production",
534 Anchor::gated_line,
535 |it| {
536 format!(
537 "Package '{}' is a devDependency imported by production code at runtime.\n\nMove it from devDependencies to dependencies so a production-only install does not break at runtime.",
538 s(it, "package_name"),
539 )
540 },
541 );
542}
543
544fn collect_check_members(env: &Value, out: &mut Vec<Annotation>) {
545 push_each(
546 out,
547 env,
548 "unused_enum_members",
549 "Unused enum member",
550 Anchor::line_col,
551 |it| {
552 format!(
553 "Enum member '{}.{}' is never referenced in the codebase.\n\nConsider removing it to keep the enum minimal.{}",
554 s(it, "parent_name"),
555 s(it, "member_name"),
556 caveat_note(it),
557 )
558 },
559 );
560 push_each(
561 out,
562 env,
563 "unused_class_members",
564 "Unused class member",
565 Anchor::line_col,
566 |it| {
567 format!(
568 "Class member '{}.{}' is never referenced.\n\nConsider removing it or marking it as private.{}",
569 s(it, "parent_name"),
570 s(it, "member_name"),
571 caveat_note(it),
572 )
573 },
574 );
575 push_each(
576 out,
577 env,
578 "unused_store_members",
579 "Unused store member",
580 Anchor::line_col,
581 |it| {
582 format!(
583 "Store member '{}.{}' is never accessed by any consumer.\n\nConsider removing the unused store state, getter, or action.{}",
584 s(it, "parent_name"),
585 s(it, "member_name"),
586 caveat_note(it),
587 )
588 },
589 );
590}
591
592fn collect_check_graph(env: &Value, out: &mut Vec<Annotation>) {
593 push_each(
594 out,
595 env,
596 "unresolved_imports",
597 "Unresolved import",
598 Anchor::line_col,
599 |it| {
600 format!(
601 "Import '{}' could not be resolved to a file or package.\n\nCheck for typos, missing dependencies, or incorrect path aliases.",
602 s(it, "specifier"),
603 )
604 },
605 );
606 for duplicate in arr(env, "duplicate_exports") {
607 let name = s(duplicate, "export_name");
608 let locations: Vec<&Value> = arr(duplicate, "locations").collect();
609 let listing = locations
610 .iter()
611 .map(|location| {
612 format!(
613 " \u{2022} {}:{}",
614 s(location, "path"),
615 num(location, "line")
616 )
617 })
618 .collect::<Vec<_>>()
619 .join("\n");
620 for location in &locations {
621 push(
622 out,
623 gate_level(duplicate, AnnotationLevel::Warning),
624 s(location, "path"),
625 Anchor::line_col(location).with_introduced(duplicate),
626 "Duplicate export".to_owned(),
627 format!(
628 "Export '{name}' is defined in {} modules:\n{listing}\n\nThis causes ambiguity for consumers. Keep one canonical location.",
629 locations.len(),
630 ),
631 );
632 }
633 }
634 for cycle in arr(env, "circular_dependencies") {
635 let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
636 let first = files.first().copied().unwrap_or_default();
637 push(
638 out,
639 gate_level(cycle, AnnotationLevel::Warning),
640 first,
641 Anchor::gated_line_col(cycle),
642 "Circular dependency".to_owned(),
643 format!(
644 "Circular import chain detected:\n{} \u{2192} {first}\n\nCircular dependencies can cause initialization bugs and make code harder to reason about.\nConsider extracting shared logic into a separate module.",
645 files.join(" \u{2192} "),
646 ),
647 );
648 }
649 for cycle in arr(env, "re_export_cycles") {
650 let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
651 let kind = s(cycle, "kind");
652 let headline = if kind == "self-loop" {
653 "Self-loop: this file re-exports from itself.".to_owned()
654 } else {
655 format!(
656 "Re-export cycle ({} files): {}.",
657 files.len(),
658 files.join(" <-> "),
659 )
660 };
661 let remedy = if kind == "self-loop" {
662 "Remove the `export * from './'` (or equivalent) inside this file."
663 } else {
664 "Remove one `export * from` statement on any one member file to break the cycle."
665 };
666 push(
667 out,
668 gate_level(cycle, AnnotationLevel::Warning),
669 files.first().copied().unwrap_or_default(),
670 no_anchor(cycle),
671 "Re-export cycle".to_owned(),
672 format!(
673 "{headline}\n\nChain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n{remedy}",
674 ),
675 );
676 }
677 collect_check_package_cycles(env, out);
678}
679
680fn collect_check_package_cycles(env: &Value, out: &mut Vec<Annotation>) {
681 for cycle in arr(env, "package_cycles") {
682 let packages: Vec<&str> = arr(cycle, "packages").filter_map(Value::as_str).collect();
683 let Some(anchor) = arr(cycle, "edges").next() else {
684 continue;
685 };
686 let first = packages.first().copied().unwrap_or_default();
687 let hops = arr(cycle, "edges")
688 .map(|edge| {
689 let type_tag = if edge.get("type_only").and_then(Value::as_bool) == Some(true) {
690 " (type-only)"
691 } else {
692 ""
693 };
694 format!(
695 " \u{2022} {} \u{2192} {}: {}:{}{type_tag}",
696 s(edge, "from_package"),
697 s(edge, "to_package"),
698 s(edge, "path"),
699 num(edge, "line"),
700 )
701 })
702 .collect::<Vec<_>>()
703 .join("\n");
704 let note = if cycle.get("group_truncated").and_then(Value::as_bool) == Some(true) {
705 format!(
706 "\nNote: {}. Break this cycle and run again to see the rest.",
707 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
708 )
709 } else {
710 String::new()
711 };
712 push(
713 out,
714 gate_level(cycle, AnnotationLevel::Warning),
715 s(anchor, "path"),
716 Anchor::line_col(anchor).with_introduced(cycle),
717 "Package cycle".to_owned(),
718 format!(
719 "Workspace packages import each other in a loop:\n{} \u{2192} {first}\n{hops}\n\nPackages in a cycle cannot be built in dependency order.\nRemove the imports on one hop to break the cycle.{note}",
720 packages.join(" \u{2192} "),
721 ),
722 );
723 }
724}
725
726fn collect_check_boundaries(env: &Value, out: &mut Vec<Annotation>) {
727 for violation in arr(env, "boundary_violations") {
728 push(
729 out,
730 gate_level(violation, AnnotationLevel::Warning),
731 s(violation, "from_path"),
732 Anchor::gated_line_col(violation),
733 "Boundary violation".to_owned(),
734 format!(
735 "Import from zone '{}' to zone '{}' is not allowed.\n{} -> {}\n\nRoute the import through an allowed zone or restructure the dependency.",
736 s(violation, "from_zone"),
737 s(violation, "to_zone"),
738 s(violation, "from_path"),
739 s(violation, "to_path"),
740 ),
741 );
742 }
743 push_each(
744 out,
745 env,
746 "boundary_coverage_violations",
747 "Boundary coverage",
748 Anchor::gated_line_col,
749 |_| {
750 "File does not match any configured architecture boundary zone.\n\nAdd the file to a zone pattern or allow-list it with boundaries.coverage.allowUnmatched.".to_owned()
751 },
752 );
753 push_each(
754 out,
755 env,
756 "boundary_call_violations",
757 "Boundary call violation",
758 Anchor::gated_line_col,
759 |it| {
760 format!(
761 "Call to '{}' matches forbidden pattern '{}' in zone '{}'.\n\nMove the call behind an allowed abstraction or adjust boundaries.calls.forbidden.",
762 s(it, "callee"),
763 s(it, "pattern"),
764 s(it, "zone"),
765 )
766 },
767 );
768 for violation in arr(env, "policy_violations") {
769 let level = if s(violation, "severity") == "error" {
770 AnnotationLevel::Error
771 } else {
772 AnnotationLevel::Warning
773 };
774 let message_suffix = violation
775 .get("message")
776 .and_then(Value::as_str)
777 .map(|message| format!("\n\n{message}"))
778 .unwrap_or_default();
779 push(
780 out,
781 level,
782 s(violation, "path"),
783 Anchor::gated_line_col(violation),
784 "Policy violation".to_owned(),
785 format!(
786 "'{}' is banned by rule '{}/{}'.{message_suffix}",
787 s(violation, "matched"),
788 s(violation, "pack"),
789 s(violation, "rule_id"),
790 ),
791 );
792 }
793}
794
795fn collect_check_frameworks(env: &Value, out: &mut Vec<Annotation>) {
796 push_each(
797 out,
798 env,
799 "invalid_client_exports",
800 "Invalid client export",
801 Anchor::line_col,
802 |it| {
803 format!(
804 "Export '{}' is not allowed in a \"{directive}\" file (Next.js server-only / route-config name).\n\nMove the server-only export to a non-client module, or remove the \"{directive}\" directive.",
805 s(it, "export_name"),
806 directive = s(it, "directive"),
807 )
808 },
809 );
810 push_each(
811 out,
812 env,
813 "mixed_client_server_barrels",
814 "Mixed client/server barrel",
815 Anchor::line_col,
816 |it| {
817 format!(
818 "This barrel re-exports both a \"use client\" module ('{}') and a server-only module ('{}'); one import drags the other's directive across the boundary.\n\nSplit the barrel so client and server-only modules are re-exported from separate entry points.",
819 s(it, "client_origin"),
820 s(it, "server_origin"),
821 )
822 },
823 );
824 push_each(
825 out,
826 env,
827 "misplaced_directives",
828 "Misplaced directive",
829 Anchor::line_col,
830 |it| {
831 format!(
832 "Directive \"{}\" is not in the leading position, so the RSC bundler ignores it.\n\nMove the directive to the very top of the file, above every import.",
833 s(it, "directive"),
834 )
835 },
836 );
837 push_each(
838 out,
839 env,
840 "unused_server_actions",
841 "Unused server action",
842 Anchor::line_col,
843 |it| {
844 format!(
845 "Server Action '{}' in this \"use server\" file is referenced by no project code.\n\nThe action stays POST-able, but nothing calls it. Remove it to shrink the action surface, or wire it up to a consumer.",
846 s(it, "action_name"),
847 )
848 },
849 );
850 push_each(
851 out,
852 env,
853 "route_collisions",
854 "Route collision",
855 no_anchor,
856 |it| {
857 format!(
858 "This route file resolves to '{}', also owned by {} other file(s). Next.js fails the build because a URL can have only one owner.\n\nMove or merge one of the colliding files; route groups and parallel slots do not change the URL.",
859 s(it, "url"),
860 arr(it, "conflicting_paths").count(),
861 )
862 },
863 );
864 push_each(
865 out,
866 env,
867 "dynamic_segment_name_conflicts",
868 "Dynamic segment conflict",
869 no_anchor,
870 |it| {
871 format!(
872 "Dynamic segments at '{}' use different slug names ({}). Next.js requires one consistent name per dynamic path.\n\nRename the dynamic segments at this position to a single slug name.",
873 s(it, "position"),
874 joined_strs(it, "conflicting_segments", ", "),
875 )
876 },
877 );
878}
879
880fn collect_check_components(env: &Value, out: &mut Vec<Annotation>) {
881 push_each(
882 out,
883 env,
884 "unrendered_components",
885 "Unrendered component",
886 Anchor::line_col,
887 |it| {
888 format!(
889 "{} component '{}' is reachable but rendered nowhere: no tag, no dynamic binding, no registration.\n\nRender it where it is needed, or remove the component and the re-export keeping it reachable.",
890 s(it, "framework"),
891 s(it, "component_name"),
892 )
893 },
894 );
895 push_each(
896 out,
897 env,
898 "absent_component_props",
899 "Absent optional component prop",
900 Anchor::line_col,
901 |it| {
902 format!(
903 "Optional prop '{}.{}' is absent from inspected callers. {}",
904 s(it, "component_name"),
905 s(it, "prop_name"),
906 s(it, "explanation")
907 )
908 },
909 );
910 push_each(
911 out,
912 env,
913 "unused_component_props",
914 "Unused component prop",
915 Anchor::line_col,
916 |it| {
917 format!(
918 "Prop '{}' on component '{}' is referenced nowhere in its own component (neither script nor template).\n\nRemove the prop, or use it. If it is part of a deliberately-stable public API, suppress this finding.",
919 s(it, "prop_name"),
920 s(it, "component_name"),
921 )
922 },
923 );
924 push_each(
925 out,
926 env,
927 "unused_component_emits",
928 "Unused component emit",
929 Anchor::line_col,
930 |it| {
931 format!(
932 "Emit '{}' on component '{}' is emitted nowhere in its own component.\n\nRemove the emit, or emit it. If it is part of a deliberately-stable public API, suppress this finding.",
933 s(it, "emit_name"),
934 s(it, "component_name"),
935 )
936 },
937 );
938 push_each(
939 out,
940 env,
941 "unused_component_inputs",
942 "Unused component input",
943 Anchor::line_col,
944 |it| {
945 format!(
946 "Input '{}' on component '{}' is read nowhere in its own component (neither class body nor template).\n\nRemove the input, or use it. If it is part of a deliberately-stable public API, suppress this finding.",
947 s(it, "input_name"),
948 s(it, "component_name"),
949 )
950 },
951 );
952 push_each(
953 out,
954 env,
955 "unused_component_outputs",
956 "Unused component output",
957 Anchor::line_col,
958 |it| {
959 format!(
960 "Output '{}' on component '{}' is emitted nowhere in its own component.\n\nRemove the output, or emit it. If it is part of a deliberately-stable public API, suppress this finding.",
961 s(it, "output_name"),
962 s(it, "component_name"),
963 )
964 },
965 );
966 collect_check_component_wiring(env, out);
967}
968
969fn collect_check_component_wiring(env: &Value, out: &mut Vec<Annotation>) {
970 push_each(
971 out,
972 env,
973 "unused_svelte_events",
974 "Unused Svelte event",
975 Anchor::line_col,
976 |it| {
977 format!(
978 "Event '{}' dispatched by component '{}' is listened to nowhere in the project.\n\nRemove the dispatched event, or listen for it. If it is part of a deliberately-stable public API, suppress this finding.",
979 s(it, "event_name"),
980 s(it, "component_name"),
981 )
982 },
983 );
984 push_each(
985 out,
986 env,
987 "unused_effect_layers",
988 "Unused Effect layer",
989 Anchor::line_col,
990 |it| {
991 format!(
992 "Layer '{}' is composed only into layers that nothing uses.\n\nRemove the unused consumer layers first, then this layer, or compose it into a layer that reaches a runtime.",
993 s(it, "name"),
994 )
995 },
996 );
997 push_each(
998 out,
999 env,
1000 "unused_tagged_errors",
1001 "Unused tagged error",
1002 Anchor::line_col,
1003 |it| {
1004 format!(
1005 "Tagged error '{}' (tag '{}') is never constructed or failed with in the project.\n\nFail with it where the condition occurs, or remove the class and its catchTag handlers.",
1006 s(it, "name"),
1007 s(it, "tag"),
1008 )
1009 },
1010 );
1011 push_each(
1012 out,
1013 env,
1014 "unproduced_catch_tags",
1015 "Unproduced catch tag",
1016 Anchor::line_col,
1017 |it| {
1018 format!(
1019 "No code in the project produces tag '{}', so this catchTag handler never runs.\n\nRemove the handler, or fail with the error where the condition occurs.",
1020 s(it, "tag"),
1021 )
1022 },
1023 );
1024 push_each(
1025 out,
1026 env,
1027 "unprovided_injects",
1028 "Unprovided inject",
1029 Anchor::line_col,
1030 |it| {
1031 format!(
1032 "{} inject for key '{}' has no matching provider in the project.\n\nAdd a provide/setContext for this key, or remove the dead inject.",
1033 s(it, "framework"),
1034 s(it, "key_name"),
1035 )
1036 },
1037 );
1038 push_each(
1039 out,
1040 env,
1041 "unused_load_data_keys",
1042 "Unused load data key",
1043 Anchor::line_only,
1044 |it| {
1045 format!(
1046 "SvelteKit load() return key '{}' is read by no consumer (neither the sibling +page.svelte nor $page.data).\n\nThe key runs a real server fetch / DB cost per request for data nothing renders. Remove the key, or use it.",
1047 s(it, "key_name"),
1048 )
1049 },
1050 );
1051}
1052
1053fn stale_suppression_message(item: &Value) -> (String, String) {
1054 let origin = item.get("origin").cloned().unwrap_or(Value::Null);
1055 let comment_form = if b(&origin, "is_file_level") {
1056 "fallow-ignore-file"
1057 } else {
1058 "fallow-ignore-next-line"
1059 };
1060 if s(&origin, "type") == "jsdoc_tag" {
1061 return (
1062 "Stale @expected-unused".to_owned(),
1063 format!(
1064 "The @expected-unused tag on '{}' is stale because the export is now used.\n\nRemove the @expected-unused tag.",
1065 s(&origin, "export_name"),
1066 ),
1067 );
1068 }
1069 if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
1070 return (
1071 "Unknown suppression kind".to_owned(),
1072 format!(
1073 "'{}' is not a recognized fallow issue kind. Other tokens on this '{comment_form}' line still apply.\n\nFix the typo or remove the unknown token.",
1074 s(&origin, "issue_kind"),
1075 ),
1076 );
1077 }
1078 let kind_clause = origin
1079 .get("issue_kind")
1080 .and_then(Value::as_str)
1081 .map(|kind| format!(" for '{kind}'"))
1082 .unwrap_or_default();
1083 (
1084 "Stale suppression".to_owned(),
1085 format!(
1086 "This '{comment_form}' comment{kind_clause} no longer matches any active issue.\n\nRemove the suppression comment to keep the codebase clean.",
1087 ),
1088 )
1089}
1090
1091fn collect_check_suppressions(env: &Value, out: &mut Vec<Annotation>) {
1092 for item in arr(env, "stale_suppressions") {
1093 let (title, message) = stale_suppression_message(item);
1094 push(
1095 out,
1096 gate_level(item, AnnotationLevel::Warning),
1097 s(item, "path"),
1098 Anchor::line_col(item),
1099 title,
1100 message,
1101 );
1102 }
1103}
1104
1105fn unresolved_catalog_reference_message(item: &Value) -> String {
1106 let catalog = s(item, "catalog_name");
1107 let (reference, described) = if catalog == "default" {
1108 (String::new(), "the default catalog".to_owned())
1109 } else {
1110 (catalog.to_owned(), format!("catalog '{catalog}'"))
1111 };
1112 let available = joined_strs(item, "available_in_catalogs", ", ");
1113 let remedy = if available.is_empty() {
1114 "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version.".to_owned()
1115 } else {
1116 format!(
1117 "Available in: {available}.\nSwitch the reference to a catalog that declares this package, or add it to the named catalog.",
1118 )
1119 };
1120 format!(
1121 "Package '{}' is referenced via `catalog:{reference}` but {described} does not declare it. `pnpm install` will fail.\n\n{remedy}",
1122 s(item, "entry_name"),
1123 )
1124}
1125
1126fn collect_check_catalog(env: &Value, out: &mut Vec<Annotation>) {
1127 push_each(
1128 out,
1129 env,
1130 "unused_catalog_entries",
1131 "Unused catalog entry",
1132 Anchor::line_only,
1133 |it| {
1134 let consumers = joined_strs(it, "hardcoded_consumers", ", ");
1135 let remedy = if consumers.is_empty() {
1136 "Remove the entry from pnpm-workspace.yaml.".to_owned()
1137 } else {
1138 format!(
1139 "Hardcoded consumers: {consumers}.\nSwitch them to catalog: before removing."
1140 )
1141 };
1142 format!(
1143 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package via the catalog: protocol.\n\n{remedy}",
1144 s(it, "entry_name"),
1145 s(it, "catalog_name"),
1146 )
1147 },
1148 );
1149 push_each(
1150 out,
1151 env,
1152 "empty_catalog_groups",
1153 "Empty catalog group",
1154 Anchor::line_only,
1155 |it| {
1156 format!(
1157 "Catalog group '{}' has no entries.\n\nRemove the empty group header from pnpm-workspace.yaml.",
1158 s(it, "catalog_name"),
1159 )
1160 },
1161 );
1162 for item in arr(env, "unresolved_catalog_references") {
1163 push(
1164 out,
1165 gate_level(item, AnnotationLevel::Error),
1166 s(item, "path"),
1167 Anchor::line_only(item),
1168 "Unresolved catalog reference".to_owned(),
1169 unresolved_catalog_reference_message(item),
1170 );
1171 }
1172 push_each(
1173 out,
1174 env,
1175 "unused_dependency_overrides",
1176 "Unused dependency override",
1177 Anchor::line_only,
1178 |it| {
1179 let target = s(it, "target_package");
1180 let hint = it
1181 .get("hint")
1182 .and_then(Value::as_str)
1183 .map(|hint| format!("{hint}.\n"))
1184 .unwrap_or_default();
1185 format!(
1186 "Override `{}` forces `{target}` to `{}` but no workspace package depends on `{target}`.\n\n{hint}Delete the entry, or scope it under a real parent (`pkg>{target}`) if it pins a transitive.",
1187 s(it, "raw_key"),
1188 s(it, "version_range"),
1189 )
1190 },
1191 );
1192 for item in arr(env, "misconfigured_dependency_overrides") {
1193 let reason = item
1194 .get("reason")
1195 .and_then(Value::as_str)
1196 .unwrap_or("unparsable");
1197 push(
1198 out,
1199 gate_level(item, AnnotationLevel::Error),
1200 s(item, "path"),
1201 Anchor::line_only(item),
1202 "Misconfigured dependency override".to_owned(),
1203 format!(
1204 "Override `{}` -> `{}` is malformed ({reason}). The active package manager will reject or ignore this entry.\n\nFix the key or value to match its override grammar, or remove the entry.",
1205 s(item, "raw_key"),
1206 s(item, "raw_value"),
1207 ),
1208 );
1209 }
1210}
1211
1212fn short_path(path: &str) -> String {
1213 let segments: Vec<&str> = path.split('/').collect();
1214 if segments.len() > 3 {
1215 segments[segments.len() - 3..].join("/")
1216 } else {
1217 segments.join("/")
1218 }
1219}
1220
1221fn collect_dupes(env: &Value, out: &mut Vec<Annotation>) {
1222 for group in arr(env, "clone_groups") {
1223 let instances: Vec<&Value> = arr(group, "instances").collect();
1224 for instance in &instances {
1225 let others = instances
1228 .iter()
1229 .filter(|other| ***other != **instance)
1230 .fold(String::new(), |mut acc, other| {
1231 use std::fmt::Write as _;
1232 let _ = write!(
1233 acc,
1234 "\n \u{2192} {}:{}-{}",
1235 short_path(s(other, "file")),
1236 num(other, "start_line"),
1237 num(other, "end_line"),
1238 );
1239 acc
1240 });
1241 out.push(Annotation {
1242 level: AnnotationLevel::Warning,
1243 path: s(instance, "file").to_owned(),
1244 line: Some(u(instance, "start_line")),
1245 end_line: Some(u(instance, "end_line")),
1246 col: Some(one_based_col(u(instance, "start_col"))),
1247 title: "Code duplication".to_owned(),
1248 message: format!(
1249 "{} duplicated lines ({} tokens)\n\n{} instances found. Also in:{others}\n\nExtract a shared function to eliminate this duplication.",
1250 num(group, "line_count"),
1251 num(group, "token_count"),
1252 instances.len(),
1253 ),
1254 introduced: group.get("introduced").and_then(Value::as_bool),
1255 });
1256 }
1257 }
1258}
1259
1260fn threshold(env: &Value, key: &str, default: &str) -> String {
1261 env.get("summary")
1262 .and_then(|summary| summary.get(key))
1263 .filter(|value| !value.is_null())
1264 .map_or_else(|| default.to_owned(), fmt_num)
1265}
1266
1267fn complexity_level(finding: &Value, band: &str) -> AnnotationLevel {
1275 let band_level = if matches!(band, "critical" | "high") {
1276 AnnotationLevel::Error
1277 } else {
1278 AnnotationLevel::Warning
1279 };
1280 gate_level(finding, band_level)
1281}
1282
1283struct ComplexityThresholds {
1284 cyclomatic: String,
1285 cognitive: String,
1286 crap: String,
1287}
1288
1289fn finding_thresholds(finding: &Value, run: &ComplexityThresholds) -> ComplexityThresholds {
1295 let effective = finding.get("effective_thresholds");
1296 let pick = |key: &str, fallback: &str| {
1297 effective
1298 .and_then(|thresholds| thresholds.get(key))
1299 .filter(|value| !value.is_null())
1300 .map_or_else(|| fallback.to_owned(), fmt_num)
1301 };
1302 ComplexityThresholds {
1303 cyclomatic: pick("max_cyclomatic", &run.cyclomatic),
1304 cognitive: pick("max_cognitive", &run.cognitive),
1305 crap: pick("max_crap", &run.crap),
1306 }
1307}
1308
1309fn complexity_annotation(finding: &Value, ctx: &ComplexityThresholds) -> (String, String) {
1310 let severity = finding
1311 .get("severity")
1312 .and_then(Value::as_str)
1313 .unwrap_or("moderate");
1314 let name = s(finding, "name");
1315 let cyclomatic = num(finding, "cyclomatic");
1316 let cognitive = num(finding, "cognitive");
1317 let lines = num(finding, "line_count");
1318 let crap_line = finding
1319 .get("crap")
1320 .filter(|crap| !crap.is_null())
1321 .map(|crap| {
1322 format!(
1323 " \u{2022} CRAP: {} (threshold: {})\n",
1324 fmt_num(crap),
1325 ctx.crap
1326 )
1327 })
1328 .unwrap_or_default();
1329 match s(finding, "exceeded") {
1330 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all" => (
1331 format!("High CRAP score ({severity})"),
1332 format!(
1333 "Function '{name}' has a CRAP score of {} (threshold: {}).\n\n \u{2022} Severity: {severity}\n \u{2022} Cyclomatic: {cyclomatic}\n \u{2022} Cognitive: {cognitive}\n{crap_line} \u{2022} Lines: {lines}\n\nCRAP combines complexity with coverage: high CRAP means changes here carry high risk.\nConsider adding tests, simplifying the function, or both.",
1334 num(finding, "crap"),
1335 ctx.crap,
1336 ),
1337 ),
1338 "both" => (
1339 format!("High complexity ({severity})"),
1340 format!(
1341 "Function '{name}' exceeds both complexity thresholds:\n\n \u{2022} Severity: {severity}\n \u{2022} Cyclomatic: {cyclomatic} (threshold: {})\n \u{2022} Cognitive: {cognitive} (threshold: {})\n{crap_line} \u{2022} Lines: {lines}\n\nConsider splitting this function into smaller, focused functions.",
1342 ctx.cyclomatic, ctx.cognitive,
1343 ),
1344 ),
1345 "cyclomatic" => (
1346 format!("High cyclomatic complexity ({severity})"),
1347 format!(
1348 "Function '{name}' has {cyclomatic} code paths (threshold: {}).\n\n \u{2022} Severity: {severity}\n \u{2022} Cyclomatic: {cyclomatic}\n \u{2022} Cognitive: {cognitive}\n{crap_line} \u{2022} Lines: {lines}\n\nHigh cyclomatic complexity means many branches to test.\nConsider extracting conditionals or using early returns.",
1349 ctx.cyclomatic,
1350 ),
1351 ),
1352 _ => (
1353 format!("High cognitive complexity ({severity})"),
1354 format!(
1355 "Function '{name}' is hard to understand (cognitive: {cognitive}, threshold: {}).\n\n \u{2022} Severity: {severity}\n \u{2022} Cyclomatic: {cyclomatic}\n \u{2022} Cognitive: {cognitive}\n{crap_line} \u{2022} Lines: {lines}\n\nHigh cognitive complexity means deeply nested or interleaved logic.\nConsider flattening control flow or extracting helper functions.",
1356 ctx.cognitive,
1357 ),
1358 ),
1359 }
1360}
1361
1362fn collect_health(env: &Value, out: &mut Vec<Annotation>) {
1363 let ctx = ComplexityThresholds {
1364 cyclomatic: threshold(env, "max_cyclomatic_threshold", "20"),
1365 cognitive: threshold(env, "max_cognitive_threshold", "15"),
1366 crap: threshold(env, "max_crap_threshold", "30"),
1367 };
1368 for finding in arr(env, "findings") {
1369 let severity = finding
1370 .get("severity")
1371 .and_then(Value::as_str)
1372 .unwrap_or("moderate");
1373 let (title, message) = complexity_annotation(finding, &finding_thresholds(finding, &ctx));
1374 push(
1375 out,
1376 complexity_level(finding, severity),
1377 s(finding, "path"),
1378 Anchor::line_col(finding),
1379 title,
1380 message,
1381 );
1382 }
1383 collect_runtime_coverage(env, out);
1384 collect_coverage_intelligence(env, out);
1385 collect_targets(env, out);
1386}
1387
1388fn collect_audit_styling(env: &Value, out: &mut Vec<Annotation>) {
1389 for finding in arr(env, "styling_findings") {
1390 push(
1391 out,
1392 gate_level(finding, AnnotationLevel::Warning),
1393 s(finding, "path"),
1394 Anchor::line_only(finding),
1395 format!("fallow/{}", s(finding, "code")),
1396 format!(
1397 "[{}] {}: {}",
1398 s(finding, "code"),
1399 s(finding, "sub_kind"),
1400 s(finding, "value"),
1401 ),
1402 );
1403 }
1404}
1405
1406fn collect_runtime_coverage(env: &Value, out: &mut Vec<Annotation>) {
1407 let Some(runtime) = env.get("runtime_coverage") else {
1408 return;
1409 };
1410 for finding in arr(runtime, "findings") {
1411 let verdict = s(finding, "verdict");
1412 let level = if verdict == "coverage_unavailable" {
1413 AnnotationLevel::Notice
1414 } else {
1415 AnnotationLevel::Warning
1416 };
1417 let invocations = finding
1418 .get("invocations")
1419 .filter(|value| !value.is_null())
1420 .map_or_else(|| "-".to_owned(), fmt_num);
1421 let evidence = finding.get("evidence").cloned().unwrap_or(Value::Null);
1422 let tracking = evidence
1423 .get("untracked_reason")
1424 .and_then(Value::as_str)
1425 .map_or_else(
1426 || s(&evidence, "v8_tracking").to_owned(),
1427 |reason| format!("{} ({reason})", s(&evidence, "v8_tracking")),
1428 );
1429 let static_status = if evidence
1433 .get("test_only_reference")
1434 .and_then(Value::as_bool)
1435 .unwrap_or(false)
1436 {
1437 format!(
1438 "{} (referenced only from tests)",
1439 s(&evidence, "static_status")
1440 )
1441 } else {
1442 s(&evidence, "static_status").to_owned()
1443 };
1444 let advice = arr(finding, "actions")
1445 .next()
1446 .and_then(|action| action.get("description"))
1447 .and_then(Value::as_str)
1448 .unwrap_or("Review the runtime evidence before changing this path.");
1449 push(
1450 out,
1451 level,
1452 s(finding, "path"),
1453 Anchor::line_only(finding),
1454 format!("Runtime coverage ({verdict})"),
1455 format!(
1456 "Function '{}' is flagged by runtime coverage.\n\n \u{2022} Verdict: {verdict}\n \u{2022} Invocations: {invocations}\n \u{2022} Confidence: {}\n \u{2022} Static: {static_status}\n \u{2022} Tests: {}\n \u{2022} V8: {tracking}\n\n{advice}",
1457 s(finding, "function"),
1458 s(finding, "confidence"),
1459 s(&evidence, "test_coverage"),
1460 ),
1461 );
1462 }
1463}
1464
1465fn collect_coverage_intelligence(env: &Value, out: &mut Vec<Annotation>) {
1466 let Some(intelligence) = env.get("coverage_intelligence") else {
1467 return;
1468 };
1469 for finding in arr(intelligence, "findings") {
1470 let verdict = s(finding, "verdict");
1471 if matches!(verdict, "clean" | "unknown") {
1472 continue;
1473 }
1474 let recommendation = s(finding, "recommendation");
1475 let level = if matches!(verdict, "risky-change-detected" | "high-confidence-delete") {
1476 AnnotationLevel::Error
1477 } else {
1478 AnnotationLevel::Warning
1479 };
1480 let identity = finding
1481 .get("identity")
1482 .and_then(Value::as_str)
1483 .unwrap_or("code");
1484 push(
1485 out,
1486 level,
1487 s(finding, "path"),
1488 Anchor::line_only(finding),
1489 format!("Coverage intelligence ({recommendation})"),
1490 format!("'{identity}' coverage intelligence verdict: {verdict} ({recommendation})"),
1491 );
1492 }
1493}
1494
1495fn collect_targets(env: &Value, out: &mut Vec<Annotation>) {
1496 let targets = env
1497 .get("targets")
1498 .filter(|value| !value.is_null())
1499 .or_else(|| env.get("refactoring_targets"))
1500 .and_then(Value::as_array)
1501 .map(Vec::as_slice)
1502 .unwrap_or_default();
1503 for target in targets.iter().take(5) {
1506 let factors = target
1507 .get("factors")
1508 .and_then(Value::as_array)
1509 .map(|factors| {
1510 factors
1511 .iter()
1512 .map(|factor| {
1513 let detail = factor
1514 .get("detail")
1515 .and_then(Value::as_str)
1516 .map_or_else(|| num(factor, "value"), str::to_owned);
1517 format!(" \u{2022} {}: {detail}", s(factor, "metric"))
1518 })
1519 .collect::<Vec<_>>()
1520 .join("\n")
1521 })
1522 .unwrap_or_default();
1523 push(
1524 out,
1525 AnnotationLevel::Notice,
1526 s(target, "path"),
1527 Anchor::default(),
1528 format!("Refactoring target ({} effort)", s(target, "effort")),
1529 format!(
1530 "Priority: {} | Confidence: {}\n\n{}\n\n{factors}",
1531 s(target, "priority"),
1532 s(target, "confidence"),
1533 s(target, "recommendation"),
1534 ),
1535 );
1536 }
1537}
1538
1539fn collect_security(env: &Value, out: &mut Vec<Annotation>) {
1543 for finding in arr(env, "security_findings") {
1544 let kind = s(finding, "kind");
1545 let severity = finding
1546 .get("severity")
1547 .and_then(Value::as_str)
1548 .unwrap_or("unknown");
1549 let callee = finding
1550 .get("candidate")
1551 .and_then(|candidate| candidate.get("sink"))
1552 .and_then(|sink| sink.get("callee"))
1553 .and_then(Value::as_str)
1554 .filter(|callee| !callee.is_empty())
1555 .unwrap_or("-");
1556 push(
1557 out,
1558 AnnotationLevel::Notice,
1559 s(finding, "path"),
1560 Anchor::line_col(finding),
1561 format!("Security candidate ({kind})"),
1562 format!(
1563 "Local security candidate (severity: {severity}).\n\n \u{2022} Sink: {callee}\n \u{2022} Evidence: {}\n\nTreat this as a candidate for verification, not a confirmed vulnerability.",
1564 s(finding, "evidence"),
1565 ),
1566 );
1567 }
1568}
1569
1570fn collect_diff_risk(env: &Value, out: &mut Vec<Annotation>) {
1574 for finding in arr(env, "findings") {
1575 let level = if s(finding, "severity") == "error" {
1576 AnnotationLevel::Error
1577 } else {
1578 AnnotationLevel::Warning
1579 };
1580 push(
1581 out,
1582 level,
1583 s(finding, "path"),
1584 Anchor::gated_line(finding),
1585 format!("fallow/{}", s(finding, "rule")),
1586 format!("{} ({})", s(finding, "message"), s(finding, "evidence")),
1587 );
1588 }
1589}