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 Dupes,
34 Health,
35 Audit,
36 Combined,
37 Security,
38 Fix,
42}
43
44pub(crate) fn print_annotations(kind: EnvelopeKind, envelope: &Value, root: &Path) -> ExitCode {
47 let options = resolve_render_options(root);
48 let rendered = render_annotations(kind, envelope, &options);
49 if let Some(line) = crate::report::gate_outcome_text::annotation_line(envelope) {
53 outln!("{line}");
54 }
55 if let Some(line) = crate::report::request_outcome_text::annotation_line(envelope) {
56 outln!("{line}");
57 }
58 if !rendered.is_empty() {
59 outln!("{rendered}");
60 }
61 ExitCode::SUCCESS
62}
63
64#[must_use]
68pub fn render_annotations(kind: EnvelopeKind, envelope: &Value, options: &RenderOptions) -> String {
69 let mut annotations = collect_annotations(kind, envelope, options.pm);
70 sort_annotations(&mut annotations);
71 let mut lines: Vec<String> = Vec::with_capacity(annotations.len() + 1);
72 for annotation in &mut annotations {
73 annotation.path = options.rebase.apply(&annotation.path);
74 lines.push(render_annotation(annotation));
75 }
76 if let Some(notice) = budget_notice(annotations.len()) {
77 lines.push(notice);
78 }
79 lines.join("\n")
80}
81
82pub(crate) fn collect_annotations(
83 kind: EnvelopeKind,
84 envelope: &Value,
85 pm: PackageManager,
86) -> Vec<Annotation> {
87 let mut out = Vec::new();
88 match kind {
89 EnvelopeKind::DeadCode => collect_check(envelope, pm, &mut out),
90 EnvelopeKind::Dupes => collect_dupes(envelope, &mut out),
91 EnvelopeKind::Health => collect_health(envelope, &mut out),
92 EnvelopeKind::Security => collect_security(envelope, &mut out),
93 EnvelopeKind::Audit => {
94 collect_section(envelope, "dead_code", pm, &mut out, collect_check);
95 collect_value_section(envelope, "complexity", &mut out, collect_health);
96 collect_value_section(envelope, "duplication", &mut out, collect_dupes);
97 }
98 EnvelopeKind::Combined => {
99 collect_section(envelope, "check", pm, &mut out, collect_check);
100 collect_value_section(envelope, "health", &mut out, collect_health);
101 collect_value_section(envelope, "dupes", &mut out, collect_dupes);
102 }
103 EnvelopeKind::Fix => {}
107 }
108 out
109}
110
111fn collect_section(
112 envelope: &Value,
113 key: &str,
114 pm: PackageManager,
115 out: &mut Vec<Annotation>,
116 collect: fn(&Value, PackageManager, &mut Vec<Annotation>),
117) {
118 if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
119 collect(section, pm, out);
120 }
121}
122
123fn collect_value_section(
124 envelope: &Value,
125 key: &str,
126 out: &mut Vec<Annotation>,
127 collect: fn(&Value, &mut Vec<Annotation>),
128) {
129 if let Some(section) = envelope.get(key).filter(|section| !section.is_null()) {
130 collect(section, out);
131 }
132}
133
134#[derive(Clone, Copy, Default)]
136struct Anchor {
137 line: Option<u64>,
138 col: Option<u64>,
139}
140
141impl Anchor {
142 fn line_col(item: &Value) -> Self {
144 Self {
145 line: Some(u(item, "line")),
146 col: Some(one_based_col(u(item, "col"))),
147 }
148 }
149
150 fn line_only(item: &Value) -> Self {
152 Self {
153 line: Some(u(item, "line")),
154 col: None,
155 }
156 }
157
158 fn gated_line_col(item: &Value) -> Self {
160 if u(item, "line") > 0 {
161 Self::line_col(item)
162 } else {
163 Self::default()
164 }
165 }
166
167 fn gated_line(item: &Value) -> Self {
169 let line = u(item, "line");
170 Self {
171 line: (line > 0).then_some(line),
172 col: None,
173 }
174 }
175}
176
177fn push(
178 out: &mut Vec<Annotation>,
179 level: AnnotationLevel,
180 path: &str,
181 anchor: Anchor,
182 title: String,
183 message: String,
184) {
185 out.push(Annotation {
186 level,
187 path: path.to_owned(),
188 line: anchor.line,
189 end_line: None,
190 col: anchor.col,
191 title,
192 message,
193 });
194}
195
196fn gate_level(item: &Value, fallback: AnnotationLevel) -> AnnotationLevel {
203 match item.get("effective_severity").and_then(Value::as_str) {
204 Some("error") => AnnotationLevel::Error,
205 Some("warn") => AnnotationLevel::Warning,
206 _ => fallback,
207 }
208}
209
210fn push_each(
214 out: &mut Vec<Annotation>,
215 env: &Value,
216 key: &str,
217 title: &str,
218 anchor: fn(&Value) -> Anchor,
219 message: impl Fn(&Value) -> String,
220) {
221 for item in arr(env, key) {
222 push(
223 out,
224 gate_level(item, AnnotationLevel::Warning),
225 s(item, "path"),
226 anchor(item),
227 title.to_owned(),
228 message(item),
229 );
230 }
231}
232
233fn no_anchor(_item: &Value) -> Anchor {
234 Anchor::default()
235}
236
237fn joined_strs(item: &Value, key: &str, separator: &str) -> String {
238 arr(item, key)
239 .filter_map(Value::as_str)
240 .collect::<Vec<_>>()
241 .join(separator)
242}
243
244fn caveat_note(item: &Value) -> String {
254 caveat_labels_for_tokens(arr(item, "reachability_caveats").filter_map(Value::as_str)).map_or_else(
255 String::new,
256 |labels| {
257 format!(
258 "\n\nCaveat: {labels}. A file this run did not fully read can hide the reference that would credit this, so verify before removing."
259 )
260 },
261 )
262}
263
264fn workspace_context(item: &Value) -> String {
265 let workspaces = joined_strs(item, "used_in_workspaces", ", ");
266 if workspaces.is_empty() {
267 String::new()
268 } else {
269 format!("\n\nImported in other workspaces: {workspaces}")
270 }
271}
272
273fn dependency_action(item: &Value, pm: PackageManager) -> String {
274 if arr(item, "used_in_workspaces").next().is_some() {
275 "Move this dependency to the consuming workspace package.json.".to_owned()
276 } else {
277 format!("Run: {}", pm.remove_command(s(item, "package_name")))
278 }
279}
280
281fn unused_dependency_message(item: &Value, section: &str, pm: PackageManager) -> String {
282 format!(
283 "Package '{}' is listed in {section} but never imported by this package.{}\n\n{}{}",
284 s(item, "package_name"),
285 workspace_context(item),
286 dependency_action(item, pm),
287 caveat_note(item),
288 )
289}
290
291fn collect_check(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
292 collect_check_files_and_exports(env, out);
293 collect_check_dependencies(env, pm, out);
294 collect_check_members(env, out);
295 collect_check_graph(env, out);
296 collect_check_boundaries(env, out);
297 collect_check_frameworks(env, out);
298 collect_check_components(env, out);
299 collect_check_suppressions(env, out);
300 collect_check_catalog(env, out);
301}
302
303fn collect_check_files_and_exports(env: &Value, out: &mut Vec<Annotation>) {
304 push_each(out, env, "unused_files", "Unused file", no_anchor, |it| {
305 format!(
306 "This file is not imported by any other module and unreachable from entry points.\nConsider removing it or importing it where needed.{}",
307 caveat_note(it),
308 )
309 });
310 push_each(
311 out,
312 env,
313 "unused_exports",
314 "Unused export",
315 Anchor::line_col,
316 |it| {
317 format!(
318 "{} {} '{}' 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.{}",
319 if b(it, "is_re_export") {
320 "Re-exported"
321 } else {
322 "Exported"
323 },
324 if b(it, "is_type_only") {
325 "type"
326 } else {
327 "value"
328 },
329 s(it, "export_name"),
330 caveat_note(it),
331 )
332 },
333 );
334 push_each(
335 out,
336 env,
337 "unused_types",
338 "Unused type",
339 Anchor::line_col,
340 |it| {
341 format!(
342 "{} type '{}' is never imported by other modules.\n\nIf only used internally, remove the export keyword.{}",
343 if b(it, "is_re_export") {
344 "Re-exported"
345 } else {
346 "Exported"
347 },
348 s(it, "export_name"),
349 caveat_note(it),
350 )
351 },
352 );
353 push_each(
354 out,
355 env,
356 "private_type_leaks",
357 "Private type leak",
358 Anchor::line_col,
359 |it| {
360 format!(
361 "Export '{}' references private type '{}'.\n\nExport the referenced type or remove it from the public signature.",
362 s(it, "export_name"),
363 s(it, "type_name"),
364 )
365 },
366 );
367 push_each(
368 out,
369 env,
370 "deprecated_exports_in_use",
371 "Deprecated export in use",
372 Anchor::line_col,
373 deprecated_export_message,
374 );
375}
376
377fn deprecated_export_message(item: &Value) -> String {
378 let count = u(item, "consumer_count");
379 let reason = item
380 .get("deprecated_reason")
381 .and_then(Value::as_str)
382 .map_or_else(String::new, |reason| {
383 format!("\n\nDeprecation message: {reason}")
384 });
385 format!(
386 "Deprecated export '{}' is still used by {count} consumer{}.{reason}\n\nMove the consumers to the replacement, then remove the export.",
387 s(item, "export_name"),
388 if count == 1 { "" } else { "s" },
389 )
390}
391
392fn collect_check_dependencies(env: &Value, pm: PackageManager, out: &mut Vec<Annotation>) {
393 push_each(
394 out,
395 env,
396 "unused_dependencies",
397 "Unused dependency",
398 Anchor::gated_line,
399 |it| unused_dependency_message(it, "dependencies", pm),
400 );
401 push_each(
402 out,
403 env,
404 "unused_dev_dependencies",
405 "Unused devDependency",
406 Anchor::gated_line,
407 |it| unused_dependency_message(it, "devDependencies", pm),
408 );
409 push_each(
410 out,
411 env,
412 "unused_optional_dependencies",
413 "Unused optionalDependency",
414 Anchor::gated_line,
415 |it| unused_dependency_message(it, "optionalDependencies", pm),
416 );
417 for dependency in arr(env, "unlisted_dependencies") {
418 let package = s(dependency, "package_name");
419 for site in arr(dependency, "imported_from") {
420 push(
421 out,
422 gate_level(dependency, AnnotationLevel::Warning),
423 s(site, "path"),
424 Anchor::line_col(site),
425 "Unlisted dependency".to_owned(),
426 format!(
427 "Package '{package}' is imported here but not listed in package.json.\n\nRun: {}",
428 pm.add_command(package),
429 ),
430 );
431 }
432 }
433 push_each(
434 out,
435 env,
436 "type_only_dependencies",
437 "Type-only dependency",
438 Anchor::gated_line,
439 |it| {
440 format!(
441 "Package '{}' is only used via type imports.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
442 s(it, "package_name"),
443 )
444 },
445 );
446 push_each(
447 out,
448 env,
449 "test_only_dependencies",
450 "Test-only dependency",
451 Anchor::gated_line,
452 |it| {
453 format!(
454 "Package '{}' is only imported from test or config files.\n\nMove it from dependencies to devDependencies to reduce production bundle size.",
455 s(it, "package_name"),
456 )
457 },
458 );
459 push_each(
460 out,
461 env,
462 "dev_dependencies_in_production",
463 "Dev dependency in production",
464 Anchor::gated_line,
465 |it| {
466 format!(
467 "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.",
468 s(it, "package_name"),
469 )
470 },
471 );
472}
473
474fn collect_check_members(env: &Value, out: &mut Vec<Annotation>) {
475 push_each(
476 out,
477 env,
478 "unused_enum_members",
479 "Unused enum member",
480 Anchor::line_col,
481 |it| {
482 format!(
483 "Enum member '{}.{}' is never referenced in the codebase.\n\nConsider removing it to keep the enum minimal.{}",
484 s(it, "parent_name"),
485 s(it, "member_name"),
486 caveat_note(it),
487 )
488 },
489 );
490 push_each(
491 out,
492 env,
493 "unused_class_members",
494 "Unused class member",
495 Anchor::line_col,
496 |it| {
497 format!(
498 "Class member '{}.{}' is never referenced.\n\nConsider removing it or marking it as private.{}",
499 s(it, "parent_name"),
500 s(it, "member_name"),
501 caveat_note(it),
502 )
503 },
504 );
505 push_each(
506 out,
507 env,
508 "unused_store_members",
509 "Unused store member",
510 Anchor::line_col,
511 |it| {
512 format!(
513 "Store member '{}.{}' is never accessed by any consumer.\n\nConsider removing the unused store state, getter, or action.{}",
514 s(it, "parent_name"),
515 s(it, "member_name"),
516 caveat_note(it),
517 )
518 },
519 );
520}
521
522fn collect_check_graph(env: &Value, out: &mut Vec<Annotation>) {
523 push_each(
524 out,
525 env,
526 "unresolved_imports",
527 "Unresolved import",
528 Anchor::line_col,
529 |it| {
530 format!(
531 "Import '{}' could not be resolved to a file or package.\n\nCheck for typos, missing dependencies, or incorrect path aliases.",
532 s(it, "specifier"),
533 )
534 },
535 );
536 for duplicate in arr(env, "duplicate_exports") {
537 let name = s(duplicate, "export_name");
538 let locations: Vec<&Value> = arr(duplicate, "locations").collect();
539 let listing = locations
540 .iter()
541 .map(|location| {
542 format!(
543 " \u{2022} {}:{}",
544 s(location, "path"),
545 num(location, "line")
546 )
547 })
548 .collect::<Vec<_>>()
549 .join("\n");
550 for location in &locations {
551 push(
552 out,
553 gate_level(duplicate, AnnotationLevel::Warning),
554 s(location, "path"),
555 Anchor::line_col(location),
556 "Duplicate export".to_owned(),
557 format!(
558 "Export '{name}' is defined in {} modules:\n{listing}\n\nThis causes ambiguity for consumers. Keep one canonical location.",
559 locations.len(),
560 ),
561 );
562 }
563 }
564 for cycle in arr(env, "circular_dependencies") {
565 let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
566 let first = files.first().copied().unwrap_or_default();
567 push(
568 out,
569 gate_level(cycle, AnnotationLevel::Warning),
570 first,
571 Anchor::gated_line_col(cycle),
572 "Circular dependency".to_owned(),
573 format!(
574 "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.",
575 files.join(" \u{2192} "),
576 ),
577 );
578 }
579 for cycle in arr(env, "re_export_cycles") {
580 let files: Vec<&str> = arr(cycle, "files").filter_map(Value::as_str).collect();
581 let kind = s(cycle, "kind");
582 let headline = if kind == "self-loop" {
583 "Self-loop: this file re-exports from itself.".to_owned()
584 } else {
585 format!(
586 "Re-export cycle ({} files): {}.",
587 files.len(),
588 files.join(" <-> "),
589 )
590 };
591 let remedy = if kind == "self-loop" {
592 "Remove the `export * from './'` (or equivalent) inside this file."
593 } else {
594 "Remove one `export * from` statement on any one member file to break the cycle."
595 };
596 push(
597 out,
598 gate_level(cycle, AnnotationLevel::Warning),
599 files.first().copied().unwrap_or_default(),
600 Anchor::default(),
601 "Re-export cycle".to_owned(),
602 format!(
603 "{headline}\n\nChain propagation through the loop is a no-op, so imports through any member may silently come up empty.\n{remedy}",
604 ),
605 );
606 }
607 collect_check_package_cycles(env, out);
608}
609
610fn collect_check_package_cycles(env: &Value, out: &mut Vec<Annotation>) {
611 for cycle in arr(env, "package_cycles") {
612 let packages: Vec<&str> = arr(cycle, "packages").filter_map(Value::as_str).collect();
613 let Some(anchor) = arr(cycle, "edges").next() else {
614 continue;
615 };
616 let first = packages.first().copied().unwrap_or_default();
617 let hops = arr(cycle, "edges")
618 .map(|edge| {
619 let type_tag = if edge.get("type_only").and_then(Value::as_bool) == Some(true) {
620 " (type-only)"
621 } else {
622 ""
623 };
624 format!(
625 " \u{2022} {} \u{2192} {}: {}:{}{type_tag}",
626 s(edge, "from_package"),
627 s(edge, "to_package"),
628 s(edge, "path"),
629 num(edge, "line"),
630 )
631 })
632 .collect::<Vec<_>>()
633 .join("\n");
634 let note = if cycle.get("group_truncated").and_then(Value::as_bool) == Some(true) {
635 format!(
636 "\nNote: {}. Break this cycle and run again to see the rest.",
637 fallow_types::results::PackageCycle::GROUP_TRUNCATED_NOTE
638 )
639 } else {
640 String::new()
641 };
642 push(
643 out,
644 gate_level(cycle, AnnotationLevel::Warning),
645 s(anchor, "path"),
646 Anchor::line_col(anchor),
647 "Package cycle".to_owned(),
648 format!(
649 "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}",
650 packages.join(" \u{2192} "),
651 ),
652 );
653 }
654}
655
656fn collect_check_boundaries(env: &Value, out: &mut Vec<Annotation>) {
657 for violation in arr(env, "boundary_violations") {
658 push(
659 out,
660 gate_level(violation, AnnotationLevel::Warning),
661 s(violation, "from_path"),
662 Anchor::gated_line_col(violation),
663 "Boundary violation".to_owned(),
664 format!(
665 "Import from zone '{}' to zone '{}' is not allowed.\n{} -> {}\n\nRoute the import through an allowed zone or restructure the dependency.",
666 s(violation, "from_zone"),
667 s(violation, "to_zone"),
668 s(violation, "from_path"),
669 s(violation, "to_path"),
670 ),
671 );
672 }
673 push_each(
674 out,
675 env,
676 "boundary_coverage_violations",
677 "Boundary coverage",
678 Anchor::gated_line_col,
679 |_| {
680 "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()
681 },
682 );
683 push_each(
684 out,
685 env,
686 "boundary_call_violations",
687 "Boundary call violation",
688 Anchor::gated_line_col,
689 |it| {
690 format!(
691 "Call to '{}' matches forbidden pattern '{}' in zone '{}'.\n\nMove the call behind an allowed abstraction or adjust boundaries.calls.forbidden.",
692 s(it, "callee"),
693 s(it, "pattern"),
694 s(it, "zone"),
695 )
696 },
697 );
698 for violation in arr(env, "policy_violations") {
699 let level = if s(violation, "severity") == "error" {
700 AnnotationLevel::Error
701 } else {
702 AnnotationLevel::Warning
703 };
704 let message_suffix = violation
705 .get("message")
706 .and_then(Value::as_str)
707 .map(|message| format!("\n\n{message}"))
708 .unwrap_or_default();
709 push(
710 out,
711 level,
712 s(violation, "path"),
713 Anchor::gated_line_col(violation),
714 "Policy violation".to_owned(),
715 format!(
716 "'{}' is banned by rule '{}/{}'.{message_suffix}",
717 s(violation, "matched"),
718 s(violation, "pack"),
719 s(violation, "rule_id"),
720 ),
721 );
722 }
723}
724
725fn collect_check_frameworks(env: &Value, out: &mut Vec<Annotation>) {
726 push_each(
727 out,
728 env,
729 "invalid_client_exports",
730 "Invalid client export",
731 Anchor::line_col,
732 |it| {
733 format!(
734 "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.",
735 s(it, "export_name"),
736 directive = s(it, "directive"),
737 )
738 },
739 );
740 push_each(
741 out,
742 env,
743 "mixed_client_server_barrels",
744 "Mixed client/server barrel",
745 Anchor::line_col,
746 |it| {
747 format!(
748 "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.",
749 s(it, "client_origin"),
750 s(it, "server_origin"),
751 )
752 },
753 );
754 push_each(
755 out,
756 env,
757 "misplaced_directives",
758 "Misplaced directive",
759 Anchor::line_col,
760 |it| {
761 format!(
762 "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.",
763 s(it, "directive"),
764 )
765 },
766 );
767 push_each(
768 out,
769 env,
770 "unused_server_actions",
771 "Unused server action",
772 Anchor::line_col,
773 |it| {
774 format!(
775 "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.",
776 s(it, "action_name"),
777 )
778 },
779 );
780 push_each(
781 out,
782 env,
783 "route_collisions",
784 "Route collision",
785 no_anchor,
786 |it| {
787 format!(
788 "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.",
789 s(it, "url"),
790 arr(it, "conflicting_paths").count(),
791 )
792 },
793 );
794 push_each(
795 out,
796 env,
797 "dynamic_segment_name_conflicts",
798 "Dynamic segment conflict",
799 no_anchor,
800 |it| {
801 format!(
802 "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.",
803 s(it, "position"),
804 joined_strs(it, "conflicting_segments", ", "),
805 )
806 },
807 );
808}
809
810fn collect_check_components(env: &Value, out: &mut Vec<Annotation>) {
811 push_each(
812 out,
813 env,
814 "unrendered_components",
815 "Unrendered component",
816 Anchor::line_col,
817 |it| {
818 format!(
819 "{} 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.",
820 s(it, "framework"),
821 s(it, "component_name"),
822 )
823 },
824 );
825 push_each(
826 out,
827 env,
828 "unused_component_props",
829 "Unused component prop",
830 Anchor::line_col,
831 |it| {
832 format!(
833 "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.",
834 s(it, "prop_name"),
835 s(it, "component_name"),
836 )
837 },
838 );
839 push_each(
840 out,
841 env,
842 "unused_component_emits",
843 "Unused component emit",
844 Anchor::line_col,
845 |it| {
846 format!(
847 "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.",
848 s(it, "emit_name"),
849 s(it, "component_name"),
850 )
851 },
852 );
853 push_each(
854 out,
855 env,
856 "unused_component_inputs",
857 "Unused component input",
858 Anchor::line_col,
859 |it| {
860 format!(
861 "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.",
862 s(it, "input_name"),
863 s(it, "component_name"),
864 )
865 },
866 );
867 push_each(
868 out,
869 env,
870 "unused_component_outputs",
871 "Unused component output",
872 Anchor::line_col,
873 |it| {
874 format!(
875 "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.",
876 s(it, "output_name"),
877 s(it, "component_name"),
878 )
879 },
880 );
881 collect_check_component_wiring(env, out);
882}
883
884fn collect_check_component_wiring(env: &Value, out: &mut Vec<Annotation>) {
885 push_each(
886 out,
887 env,
888 "unused_svelte_events",
889 "Unused Svelte event",
890 Anchor::line_col,
891 |it| {
892 format!(
893 "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.",
894 s(it, "event_name"),
895 s(it, "component_name"),
896 )
897 },
898 );
899 push_each(
900 out,
901 env,
902 "unprovided_injects",
903 "Unprovided inject",
904 Anchor::line_col,
905 |it| {
906 format!(
907 "{} inject for key '{}' has no matching provider in the project.\n\nAdd a provide/setContext for this key, or remove the dead inject.",
908 s(it, "framework"),
909 s(it, "key_name"),
910 )
911 },
912 );
913 push_each(
914 out,
915 env,
916 "unused_load_data_keys",
917 "Unused load data key",
918 Anchor::line_only,
919 |it| {
920 format!(
921 "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.",
922 s(it, "key_name"),
923 )
924 },
925 );
926}
927
928fn stale_suppression_message(item: &Value) -> (String, String) {
929 let origin = item.get("origin").cloned().unwrap_or(Value::Null);
930 let comment_form = if b(&origin, "is_file_level") {
931 "fallow-ignore-file"
932 } else {
933 "fallow-ignore-next-line"
934 };
935 if s(&origin, "type") == "jsdoc_tag" {
936 return (
937 "Stale @expected-unused".to_owned(),
938 format!(
939 "The @expected-unused tag on '{}' is stale because the export is now used.\n\nRemove the @expected-unused tag.",
940 s(&origin, "export_name"),
941 ),
942 );
943 }
944 if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
945 return (
946 "Unknown suppression kind".to_owned(),
947 format!(
948 "'{}' 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.",
949 s(&origin, "issue_kind"),
950 ),
951 );
952 }
953 let kind_clause = origin
954 .get("issue_kind")
955 .and_then(Value::as_str)
956 .map(|kind| format!(" for '{kind}'"))
957 .unwrap_or_default();
958 (
959 "Stale suppression".to_owned(),
960 format!(
961 "This '{comment_form}' comment{kind_clause} no longer matches any active issue.\n\nRemove the suppression comment to keep the codebase clean.",
962 ),
963 )
964}
965
966fn collect_check_suppressions(env: &Value, out: &mut Vec<Annotation>) {
967 for item in arr(env, "stale_suppressions") {
968 let (title, message) = stale_suppression_message(item);
969 push(
970 out,
971 gate_level(item, AnnotationLevel::Warning),
972 s(item, "path"),
973 Anchor::line_col(item),
974 title,
975 message,
976 );
977 }
978}
979
980fn unresolved_catalog_reference_message(item: &Value) -> String {
981 let catalog = s(item, "catalog_name");
982 let (reference, described) = if catalog == "default" {
983 (String::new(), "the default catalog".to_owned())
984 } else {
985 (catalog.to_owned(), format!("catalog '{catalog}'"))
986 };
987 let available = joined_strs(item, "available_in_catalogs", ", ");
988 let remedy = if available.is_empty() {
989 "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version.".to_owned()
990 } else {
991 format!(
992 "Available in: {available}.\nSwitch the reference to a catalog that declares this package, or add it to the named catalog.",
993 )
994 };
995 format!(
996 "Package '{}' is referenced via `catalog:{reference}` but {described} does not declare it. `pnpm install` will fail.\n\n{remedy}",
997 s(item, "entry_name"),
998 )
999}
1000
1001fn collect_check_catalog(env: &Value, out: &mut Vec<Annotation>) {
1002 push_each(
1003 out,
1004 env,
1005 "unused_catalog_entries",
1006 "Unused catalog entry",
1007 Anchor::line_only,
1008 |it| {
1009 let consumers = joined_strs(it, "hardcoded_consumers", ", ");
1010 let remedy = if consumers.is_empty() {
1011 "Remove the entry from pnpm-workspace.yaml.".to_owned()
1012 } else {
1013 format!(
1014 "Hardcoded consumers: {consumers}.\nSwitch them to catalog: before removing."
1015 )
1016 };
1017 format!(
1018 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package via the catalog: protocol.\n\n{remedy}",
1019 s(it, "entry_name"),
1020 s(it, "catalog_name"),
1021 )
1022 },
1023 );
1024 push_each(
1025 out,
1026 env,
1027 "empty_catalog_groups",
1028 "Empty catalog group",
1029 Anchor::line_only,
1030 |it| {
1031 format!(
1032 "Catalog group '{}' has no entries.\n\nRemove the empty group header from pnpm-workspace.yaml.",
1033 s(it, "catalog_name"),
1034 )
1035 },
1036 );
1037 for item in arr(env, "unresolved_catalog_references") {
1038 push(
1039 out,
1040 gate_level(item, AnnotationLevel::Error),
1041 s(item, "path"),
1042 Anchor::line_only(item),
1043 "Unresolved catalog reference".to_owned(),
1044 unresolved_catalog_reference_message(item),
1045 );
1046 }
1047 push_each(
1048 out,
1049 env,
1050 "unused_dependency_overrides",
1051 "Unused dependency override",
1052 Anchor::line_only,
1053 |it| {
1054 let target = s(it, "target_package");
1055 let hint = it
1056 .get("hint")
1057 .and_then(Value::as_str)
1058 .map(|hint| format!("{hint}.\n"))
1059 .unwrap_or_default();
1060 format!(
1061 "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.",
1062 s(it, "raw_key"),
1063 s(it, "version_range"),
1064 )
1065 },
1066 );
1067 for item in arr(env, "misconfigured_dependency_overrides") {
1068 let reason = item
1069 .get("reason")
1070 .and_then(Value::as_str)
1071 .unwrap_or("unparsable");
1072 push(
1073 out,
1074 gate_level(item, AnnotationLevel::Error),
1075 s(item, "path"),
1076 Anchor::line_only(item),
1077 "Misconfigured dependency override".to_owned(),
1078 format!(
1079 "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.",
1080 s(item, "raw_key"),
1081 s(item, "raw_value"),
1082 ),
1083 );
1084 }
1085}
1086
1087fn short_path(path: &str) -> String {
1088 let segments: Vec<&str> = path.split('/').collect();
1089 if segments.len() > 3 {
1090 segments[segments.len() - 3..].join("/")
1091 } else {
1092 segments.join("/")
1093 }
1094}
1095
1096fn collect_dupes(env: &Value, out: &mut Vec<Annotation>) {
1097 for group in arr(env, "clone_groups") {
1098 let instances: Vec<&Value> = arr(group, "instances").collect();
1099 for instance in &instances {
1100 let others = instances
1103 .iter()
1104 .filter(|other| ***other != **instance)
1105 .fold(String::new(), |mut acc, other| {
1106 use std::fmt::Write as _;
1107 let _ = write!(
1108 acc,
1109 "\n \u{2192} {}:{}-{}",
1110 short_path(s(other, "file")),
1111 num(other, "start_line"),
1112 num(other, "end_line"),
1113 );
1114 acc
1115 });
1116 out.push(Annotation {
1117 level: AnnotationLevel::Warning,
1118 path: s(instance, "file").to_owned(),
1119 line: Some(u(instance, "start_line")),
1120 end_line: Some(u(instance, "end_line")),
1121 col: Some(one_based_col(u(instance, "start_col"))),
1122 title: "Code duplication".to_owned(),
1123 message: format!(
1124 "{} duplicated lines ({} tokens)\n\n{} instances found. Also in:{others}\n\nExtract a shared function to eliminate this duplication.",
1125 num(group, "line_count"),
1126 num(group, "token_count"),
1127 instances.len(),
1128 ),
1129 });
1130 }
1131 }
1132}
1133
1134fn threshold(env: &Value, key: &str, default: &str) -> String {
1135 env.get("summary")
1136 .and_then(|summary| summary.get(key))
1137 .filter(|value| !value.is_null())
1138 .map_or_else(|| default.to_owned(), fmt_num)
1139}
1140
1141fn complexity_level(finding: &Value, band: &str) -> AnnotationLevel {
1149 let band_level = if matches!(band, "critical" | "high") {
1150 AnnotationLevel::Error
1151 } else {
1152 AnnotationLevel::Warning
1153 };
1154 gate_level(finding, band_level)
1155}
1156
1157struct ComplexityThresholds {
1158 cyclomatic: String,
1159 cognitive: String,
1160 crap: String,
1161}
1162
1163fn finding_thresholds(finding: &Value, run: &ComplexityThresholds) -> ComplexityThresholds {
1169 let effective = finding.get("effective_thresholds");
1170 let pick = |key: &str, fallback: &str| {
1171 effective
1172 .and_then(|thresholds| thresholds.get(key))
1173 .filter(|value| !value.is_null())
1174 .map_or_else(|| fallback.to_owned(), fmt_num)
1175 };
1176 ComplexityThresholds {
1177 cyclomatic: pick("max_cyclomatic", &run.cyclomatic),
1178 cognitive: pick("max_cognitive", &run.cognitive),
1179 crap: pick("max_crap", &run.crap),
1180 }
1181}
1182
1183fn complexity_annotation(finding: &Value, ctx: &ComplexityThresholds) -> (String, String) {
1184 let severity = finding
1185 .get("severity")
1186 .and_then(Value::as_str)
1187 .unwrap_or("moderate");
1188 let name = s(finding, "name");
1189 let cyclomatic = num(finding, "cyclomatic");
1190 let cognitive = num(finding, "cognitive");
1191 let lines = num(finding, "line_count");
1192 let crap_line = finding
1193 .get("crap")
1194 .filter(|crap| !crap.is_null())
1195 .map(|crap| {
1196 format!(
1197 " \u{2022} CRAP: {} (threshold: {})\n",
1198 fmt_num(crap),
1199 ctx.crap
1200 )
1201 })
1202 .unwrap_or_default();
1203 match s(finding, "exceeded") {
1204 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all" => (
1205 format!("High CRAP score ({severity})"),
1206 format!(
1207 "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.",
1208 num(finding, "crap"),
1209 ctx.crap,
1210 ),
1211 ),
1212 "both" => (
1213 format!("High complexity ({severity})"),
1214 format!(
1215 "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.",
1216 ctx.cyclomatic, ctx.cognitive,
1217 ),
1218 ),
1219 "cyclomatic" => (
1220 format!("High cyclomatic complexity ({severity})"),
1221 format!(
1222 "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.",
1223 ctx.cyclomatic,
1224 ),
1225 ),
1226 _ => (
1227 format!("High cognitive complexity ({severity})"),
1228 format!(
1229 "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.",
1230 ctx.cognitive,
1231 ),
1232 ),
1233 }
1234}
1235
1236fn collect_health(env: &Value, out: &mut Vec<Annotation>) {
1237 let ctx = ComplexityThresholds {
1238 cyclomatic: threshold(env, "max_cyclomatic_threshold", "20"),
1239 cognitive: threshold(env, "max_cognitive_threshold", "15"),
1240 crap: threshold(env, "max_crap_threshold", "30"),
1241 };
1242 for finding in arr(env, "findings") {
1243 let severity = finding
1244 .get("severity")
1245 .and_then(Value::as_str)
1246 .unwrap_or("moderate");
1247 let (title, message) = complexity_annotation(finding, &finding_thresholds(finding, &ctx));
1248 push(
1249 out,
1250 complexity_level(finding, severity),
1251 s(finding, "path"),
1252 Anchor::line_col(finding),
1253 title,
1254 message,
1255 );
1256 }
1257 collect_runtime_coverage(env, out);
1258 collect_coverage_intelligence(env, out);
1259 collect_targets(env, out);
1260}
1261
1262fn collect_runtime_coverage(env: &Value, out: &mut Vec<Annotation>) {
1263 let Some(runtime) = env.get("runtime_coverage") else {
1264 return;
1265 };
1266 for finding in arr(runtime, "findings") {
1267 let verdict = s(finding, "verdict");
1268 let level = if verdict == "coverage_unavailable" {
1269 AnnotationLevel::Notice
1270 } else {
1271 AnnotationLevel::Warning
1272 };
1273 let invocations = finding
1274 .get("invocations")
1275 .filter(|value| !value.is_null())
1276 .map_or_else(|| "-".to_owned(), fmt_num);
1277 let evidence = finding.get("evidence").cloned().unwrap_or(Value::Null);
1278 let tracking = evidence
1279 .get("untracked_reason")
1280 .and_then(Value::as_str)
1281 .map_or_else(
1282 || s(&evidence, "v8_tracking").to_owned(),
1283 |reason| format!("{} ({reason})", s(&evidence, "v8_tracking")),
1284 );
1285 let static_status = if evidence
1289 .get("test_only_reference")
1290 .and_then(Value::as_bool)
1291 .unwrap_or(false)
1292 {
1293 format!(
1294 "{} (referenced only from tests)",
1295 s(&evidence, "static_status")
1296 )
1297 } else {
1298 s(&evidence, "static_status").to_owned()
1299 };
1300 let advice = arr(finding, "actions")
1301 .next()
1302 .and_then(|action| action.get("description"))
1303 .and_then(Value::as_str)
1304 .unwrap_or("Review the runtime evidence before changing this path.");
1305 push(
1306 out,
1307 level,
1308 s(finding, "path"),
1309 Anchor::line_only(finding),
1310 format!("Runtime coverage ({verdict})"),
1311 format!(
1312 "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}",
1313 s(finding, "function"),
1314 s(finding, "confidence"),
1315 s(&evidence, "test_coverage"),
1316 ),
1317 );
1318 }
1319}
1320
1321fn collect_coverage_intelligence(env: &Value, out: &mut Vec<Annotation>) {
1322 let Some(intelligence) = env.get("coverage_intelligence") else {
1323 return;
1324 };
1325 for finding in arr(intelligence, "findings") {
1326 let verdict = s(finding, "verdict");
1327 if matches!(verdict, "clean" | "unknown") {
1328 continue;
1329 }
1330 let recommendation = s(finding, "recommendation");
1331 let level = if matches!(verdict, "risky-change-detected" | "high-confidence-delete") {
1332 AnnotationLevel::Error
1333 } else {
1334 AnnotationLevel::Warning
1335 };
1336 let identity = finding
1337 .get("identity")
1338 .and_then(Value::as_str)
1339 .unwrap_or("code");
1340 push(
1341 out,
1342 level,
1343 s(finding, "path"),
1344 Anchor::line_only(finding),
1345 format!("Coverage intelligence ({recommendation})"),
1346 format!("'{identity}' coverage intelligence verdict: {verdict} ({recommendation})"),
1347 );
1348 }
1349}
1350
1351fn collect_targets(env: &Value, out: &mut Vec<Annotation>) {
1352 let targets = env
1353 .get("targets")
1354 .filter(|value| !value.is_null())
1355 .or_else(|| env.get("refactoring_targets"))
1356 .and_then(Value::as_array)
1357 .map(Vec::as_slice)
1358 .unwrap_or_default();
1359 for target in targets.iter().take(5) {
1362 let factors = target
1363 .get("factors")
1364 .and_then(Value::as_array)
1365 .map(|factors| {
1366 factors
1367 .iter()
1368 .map(|factor| {
1369 let detail = factor
1370 .get("detail")
1371 .and_then(Value::as_str)
1372 .map_or_else(|| num(factor, "value"), str::to_owned);
1373 format!(" \u{2022} {}: {detail}", s(factor, "metric"))
1374 })
1375 .collect::<Vec<_>>()
1376 .join("\n")
1377 })
1378 .unwrap_or_default();
1379 push(
1380 out,
1381 AnnotationLevel::Notice,
1382 s(target, "path"),
1383 Anchor::default(),
1384 format!("Refactoring target ({} effort)", s(target, "effort")),
1385 format!(
1386 "Priority: {} | Confidence: {}\n\n{}\n\n{factors}",
1387 s(target, "priority"),
1388 s(target, "confidence"),
1389 s(target, "recommendation"),
1390 ),
1391 );
1392 }
1393}
1394
1395fn collect_security(env: &Value, out: &mut Vec<Annotation>) {
1399 for finding in arr(env, "security_findings") {
1400 let kind = s(finding, "kind");
1401 let severity = finding
1402 .get("severity")
1403 .and_then(Value::as_str)
1404 .unwrap_or("unknown");
1405 let callee = finding
1406 .get("candidate")
1407 .and_then(|candidate| candidate.get("sink"))
1408 .and_then(|sink| sink.get("callee"))
1409 .and_then(Value::as_str)
1410 .filter(|callee| !callee.is_empty())
1411 .unwrap_or("-");
1412 push(
1413 out,
1414 AnnotationLevel::Notice,
1415 s(finding, "path"),
1416 Anchor::line_col(finding),
1417 format!("Security candidate ({kind})"),
1418 format!(
1419 "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.",
1420 s(finding, "evidence"),
1421 ),
1422 );
1423 }
1424}