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