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