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}
608
609fn collect_check_boundaries(env: &Value, out: &mut Vec<Annotation>) {
610 for violation in arr(env, "boundary_violations") {
611 push(
612 out,
613 gate_level(violation, AnnotationLevel::Warning),
614 s(violation, "from_path"),
615 Anchor::gated_line_col(violation),
616 "Boundary violation".to_owned(),
617 format!(
618 "Import from zone '{}' to zone '{}' is not allowed.\n{} -> {}\n\nRoute the import through an allowed zone or restructure the dependency.",
619 s(violation, "from_zone"),
620 s(violation, "to_zone"),
621 s(violation, "from_path"),
622 s(violation, "to_path"),
623 ),
624 );
625 }
626 push_each(
627 out,
628 env,
629 "boundary_coverage_violations",
630 "Boundary coverage",
631 Anchor::gated_line_col,
632 |_| {
633 "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()
634 },
635 );
636 push_each(
637 out,
638 env,
639 "boundary_call_violations",
640 "Boundary call violation",
641 Anchor::gated_line_col,
642 |it| {
643 format!(
644 "Call to '{}' matches forbidden pattern '{}' in zone '{}'.\n\nMove the call behind an allowed abstraction or adjust boundaries.calls.forbidden.",
645 s(it, "callee"),
646 s(it, "pattern"),
647 s(it, "zone"),
648 )
649 },
650 );
651 for violation in arr(env, "policy_violations") {
652 let level = if s(violation, "severity") == "error" {
653 AnnotationLevel::Error
654 } else {
655 AnnotationLevel::Warning
656 };
657 let message_suffix = violation
658 .get("message")
659 .and_then(Value::as_str)
660 .map(|message| format!("\n\n{message}"))
661 .unwrap_or_default();
662 push(
663 out,
664 level,
665 s(violation, "path"),
666 Anchor::gated_line_col(violation),
667 "Policy violation".to_owned(),
668 format!(
669 "'{}' is banned by rule '{}/{}'.{message_suffix}",
670 s(violation, "matched"),
671 s(violation, "pack"),
672 s(violation, "rule_id"),
673 ),
674 );
675 }
676}
677
678fn collect_check_frameworks(env: &Value, out: &mut Vec<Annotation>) {
679 push_each(
680 out,
681 env,
682 "invalid_client_exports",
683 "Invalid client export",
684 Anchor::line_col,
685 |it| {
686 format!(
687 "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.",
688 s(it, "export_name"),
689 directive = s(it, "directive"),
690 )
691 },
692 );
693 push_each(
694 out,
695 env,
696 "mixed_client_server_barrels",
697 "Mixed client/server barrel",
698 Anchor::line_col,
699 |it| {
700 format!(
701 "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.",
702 s(it, "client_origin"),
703 s(it, "server_origin"),
704 )
705 },
706 );
707 push_each(
708 out,
709 env,
710 "misplaced_directives",
711 "Misplaced directive",
712 Anchor::line_col,
713 |it| {
714 format!(
715 "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.",
716 s(it, "directive"),
717 )
718 },
719 );
720 push_each(
721 out,
722 env,
723 "unused_server_actions",
724 "Unused server action",
725 Anchor::line_col,
726 |it| {
727 format!(
728 "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.",
729 s(it, "action_name"),
730 )
731 },
732 );
733 push_each(
734 out,
735 env,
736 "route_collisions",
737 "Route collision",
738 no_anchor,
739 |it| {
740 format!(
741 "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.",
742 s(it, "url"),
743 arr(it, "conflicting_paths").count(),
744 )
745 },
746 );
747 push_each(
748 out,
749 env,
750 "dynamic_segment_name_conflicts",
751 "Dynamic segment conflict",
752 no_anchor,
753 |it| {
754 format!(
755 "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.",
756 s(it, "position"),
757 joined_strs(it, "conflicting_segments", ", "),
758 )
759 },
760 );
761}
762
763fn collect_check_components(env: &Value, out: &mut Vec<Annotation>) {
764 push_each(
765 out,
766 env,
767 "unrendered_components",
768 "Unrendered component",
769 Anchor::line_col,
770 |it| {
771 format!(
772 "{} 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.",
773 s(it, "framework"),
774 s(it, "component_name"),
775 )
776 },
777 );
778 push_each(
779 out,
780 env,
781 "unused_component_props",
782 "Unused component prop",
783 Anchor::line_col,
784 |it| {
785 format!(
786 "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.",
787 s(it, "prop_name"),
788 s(it, "component_name"),
789 )
790 },
791 );
792 push_each(
793 out,
794 env,
795 "unused_component_emits",
796 "Unused component emit",
797 Anchor::line_col,
798 |it| {
799 format!(
800 "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.",
801 s(it, "emit_name"),
802 s(it, "component_name"),
803 )
804 },
805 );
806 push_each(
807 out,
808 env,
809 "unused_component_inputs",
810 "Unused component input",
811 Anchor::line_col,
812 |it| {
813 format!(
814 "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.",
815 s(it, "input_name"),
816 s(it, "component_name"),
817 )
818 },
819 );
820 push_each(
821 out,
822 env,
823 "unused_component_outputs",
824 "Unused component output",
825 Anchor::line_col,
826 |it| {
827 format!(
828 "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.",
829 s(it, "output_name"),
830 s(it, "component_name"),
831 )
832 },
833 );
834 collect_check_component_wiring(env, out);
835}
836
837fn collect_check_component_wiring(env: &Value, out: &mut Vec<Annotation>) {
838 push_each(
839 out,
840 env,
841 "unused_svelte_events",
842 "Unused Svelte event",
843 Anchor::line_col,
844 |it| {
845 format!(
846 "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.",
847 s(it, "event_name"),
848 s(it, "component_name"),
849 )
850 },
851 );
852 push_each(
853 out,
854 env,
855 "unprovided_injects",
856 "Unprovided inject",
857 Anchor::line_col,
858 |it| {
859 format!(
860 "{} inject for key '{}' has no matching provider in the project.\n\nAdd a provide/setContext for this key, or remove the dead inject.",
861 s(it, "framework"),
862 s(it, "key_name"),
863 )
864 },
865 );
866 push_each(
867 out,
868 env,
869 "unused_load_data_keys",
870 "Unused load data key",
871 Anchor::line_only,
872 |it| {
873 format!(
874 "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.",
875 s(it, "key_name"),
876 )
877 },
878 );
879}
880
881fn stale_suppression_message(item: &Value) -> (String, String) {
882 let origin = item.get("origin").cloned().unwrap_or(Value::Null);
883 let comment_form = if b(&origin, "is_file_level") {
884 "fallow-ignore-file"
885 } else {
886 "fallow-ignore-next-line"
887 };
888 if s(&origin, "type") == "jsdoc_tag" {
889 return (
890 "Stale @expected-unused".to_owned(),
891 format!(
892 "The @expected-unused tag on '{}' is stale because the export is now used.\n\nRemove the @expected-unused tag.",
893 s(&origin, "export_name"),
894 ),
895 );
896 }
897 if origin.get("kind_known").and_then(Value::as_bool) == Some(false) {
898 return (
899 "Unknown suppression kind".to_owned(),
900 format!(
901 "'{}' 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.",
902 s(&origin, "issue_kind"),
903 ),
904 );
905 }
906 let kind_clause = origin
907 .get("issue_kind")
908 .and_then(Value::as_str)
909 .map(|kind| format!(" for '{kind}'"))
910 .unwrap_or_default();
911 (
912 "Stale suppression".to_owned(),
913 format!(
914 "This '{comment_form}' comment{kind_clause} no longer matches any active issue.\n\nRemove the suppression comment to keep the codebase clean.",
915 ),
916 )
917}
918
919fn collect_check_suppressions(env: &Value, out: &mut Vec<Annotation>) {
920 for item in arr(env, "stale_suppressions") {
921 let (title, message) = stale_suppression_message(item);
922 push(
923 out,
924 gate_level(item, AnnotationLevel::Warning),
925 s(item, "path"),
926 Anchor::line_col(item),
927 title,
928 message,
929 );
930 }
931}
932
933fn unresolved_catalog_reference_message(item: &Value) -> String {
934 let catalog = s(item, "catalog_name");
935 let (reference, described) = if catalog == "default" {
936 (String::new(), "the default catalog".to_owned())
937 } else {
938 (catalog.to_owned(), format!("catalog '{catalog}'"))
939 };
940 let available = joined_strs(item, "available_in_catalogs", ", ");
941 let remedy = if available.is_empty() {
942 "Add this package to the named catalog in pnpm-workspace.yaml, or remove the reference and pin a hardcoded version.".to_owned()
943 } else {
944 format!(
945 "Available in: {available}.\nSwitch the reference to a catalog that declares this package, or add it to the named catalog.",
946 )
947 };
948 format!(
949 "Package '{}' is referenced via `catalog:{reference}` but {described} does not declare it. `pnpm install` will fail.\n\n{remedy}",
950 s(item, "entry_name"),
951 )
952}
953
954fn collect_check_catalog(env: &Value, out: &mut Vec<Annotation>) {
955 push_each(
956 out,
957 env,
958 "unused_catalog_entries",
959 "Unused catalog entry",
960 Anchor::line_only,
961 |it| {
962 let consumers = joined_strs(it, "hardcoded_consumers", ", ");
963 let remedy = if consumers.is_empty() {
964 "Remove the entry from pnpm-workspace.yaml.".to_owned()
965 } else {
966 format!(
967 "Hardcoded consumers: {consumers}.\nSwitch them to catalog: before removing."
968 )
969 };
970 format!(
971 "Catalog entry '{}' (catalog '{}') is not referenced by any workspace package via the catalog: protocol.\n\n{remedy}",
972 s(it, "entry_name"),
973 s(it, "catalog_name"),
974 )
975 },
976 );
977 push_each(
978 out,
979 env,
980 "empty_catalog_groups",
981 "Empty catalog group",
982 Anchor::line_only,
983 |it| {
984 format!(
985 "Catalog group '{}' has no entries.\n\nRemove the empty group header from pnpm-workspace.yaml.",
986 s(it, "catalog_name"),
987 )
988 },
989 );
990 for item in arr(env, "unresolved_catalog_references") {
991 push(
992 out,
993 gate_level(item, AnnotationLevel::Error),
994 s(item, "path"),
995 Anchor::line_only(item),
996 "Unresolved catalog reference".to_owned(),
997 unresolved_catalog_reference_message(item),
998 );
999 }
1000 push_each(
1001 out,
1002 env,
1003 "unused_dependency_overrides",
1004 "Unused dependency override",
1005 Anchor::line_only,
1006 |it| {
1007 let target = s(it, "target_package");
1008 let hint = it
1009 .get("hint")
1010 .and_then(Value::as_str)
1011 .map(|hint| format!("{hint}.\n"))
1012 .unwrap_or_default();
1013 format!(
1014 "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.",
1015 s(it, "raw_key"),
1016 s(it, "version_range"),
1017 )
1018 },
1019 );
1020 for item in arr(env, "misconfigured_dependency_overrides") {
1021 let reason = item
1022 .get("reason")
1023 .and_then(Value::as_str)
1024 .unwrap_or("unparsable");
1025 push(
1026 out,
1027 gate_level(item, AnnotationLevel::Error),
1028 s(item, "path"),
1029 Anchor::line_only(item),
1030 "Misconfigured dependency override".to_owned(),
1031 format!(
1032 "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.",
1033 s(item, "raw_key"),
1034 s(item, "raw_value"),
1035 ),
1036 );
1037 }
1038}
1039
1040fn short_path(path: &str) -> String {
1041 let segments: Vec<&str> = path.split('/').collect();
1042 if segments.len() > 3 {
1043 segments[segments.len() - 3..].join("/")
1044 } else {
1045 segments.join("/")
1046 }
1047}
1048
1049fn collect_dupes(env: &Value, out: &mut Vec<Annotation>) {
1050 for group in arr(env, "clone_groups") {
1051 let instances: Vec<&Value> = arr(group, "instances").collect();
1052 for instance in &instances {
1053 let others = instances
1056 .iter()
1057 .filter(|other| ***other != **instance)
1058 .fold(String::new(), |mut acc, other| {
1059 use std::fmt::Write as _;
1060 let _ = write!(
1061 acc,
1062 "\n \u{2192} {}:{}-{}",
1063 short_path(s(other, "file")),
1064 num(other, "start_line"),
1065 num(other, "end_line"),
1066 );
1067 acc
1068 });
1069 out.push(Annotation {
1070 level: AnnotationLevel::Warning,
1071 path: s(instance, "file").to_owned(),
1072 line: Some(u(instance, "start_line")),
1073 end_line: Some(u(instance, "end_line")),
1074 col: Some(one_based_col(u(instance, "start_col"))),
1075 title: "Code duplication".to_owned(),
1076 message: format!(
1077 "{} duplicated lines ({} tokens)\n\n{} instances found. Also in:{others}\n\nExtract a shared function to eliminate this duplication.",
1078 num(group, "line_count"),
1079 num(group, "token_count"),
1080 instances.len(),
1081 ),
1082 });
1083 }
1084 }
1085}
1086
1087fn threshold(env: &Value, key: &str, default: &str) -> String {
1088 env.get("summary")
1089 .and_then(|summary| summary.get(key))
1090 .filter(|value| !value.is_null())
1091 .map_or_else(|| default.to_owned(), fmt_num)
1092}
1093
1094fn complexity_level(finding: &Value, band: &str) -> AnnotationLevel {
1102 let band_level = if matches!(band, "critical" | "high") {
1103 AnnotationLevel::Error
1104 } else {
1105 AnnotationLevel::Warning
1106 };
1107 gate_level(finding, band_level)
1108}
1109
1110struct ComplexityThresholds {
1111 cyclomatic: String,
1112 cognitive: String,
1113 crap: String,
1114}
1115
1116fn finding_thresholds(finding: &Value, run: &ComplexityThresholds) -> ComplexityThresholds {
1122 let effective = finding.get("effective_thresholds");
1123 let pick = |key: &str, fallback: &str| {
1124 effective
1125 .and_then(|thresholds| thresholds.get(key))
1126 .filter(|value| !value.is_null())
1127 .map_or_else(|| fallback.to_owned(), fmt_num)
1128 };
1129 ComplexityThresholds {
1130 cyclomatic: pick("max_cyclomatic", &run.cyclomatic),
1131 cognitive: pick("max_cognitive", &run.cognitive),
1132 crap: pick("max_crap", &run.crap),
1133 }
1134}
1135
1136fn complexity_annotation(finding: &Value, ctx: &ComplexityThresholds) -> (String, String) {
1137 let severity = finding
1138 .get("severity")
1139 .and_then(Value::as_str)
1140 .unwrap_or("moderate");
1141 let name = s(finding, "name");
1142 let cyclomatic = num(finding, "cyclomatic");
1143 let cognitive = num(finding, "cognitive");
1144 let lines = num(finding, "line_count");
1145 let crap_line = finding
1146 .get("crap")
1147 .filter(|crap| !crap.is_null())
1148 .map(|crap| {
1149 format!(
1150 " \u{2022} CRAP: {} (threshold: {})\n",
1151 fmt_num(crap),
1152 ctx.crap
1153 )
1154 })
1155 .unwrap_or_default();
1156 match s(finding, "exceeded") {
1157 "crap" | "cyclomatic_crap" | "cognitive_crap" | "all" => (
1158 format!("High CRAP score ({severity})"),
1159 format!(
1160 "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.",
1161 num(finding, "crap"),
1162 ctx.crap,
1163 ),
1164 ),
1165 "both" => (
1166 format!("High complexity ({severity})"),
1167 format!(
1168 "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.",
1169 ctx.cyclomatic, ctx.cognitive,
1170 ),
1171 ),
1172 "cyclomatic" => (
1173 format!("High cyclomatic complexity ({severity})"),
1174 format!(
1175 "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.",
1176 ctx.cyclomatic,
1177 ),
1178 ),
1179 _ => (
1180 format!("High cognitive complexity ({severity})"),
1181 format!(
1182 "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.",
1183 ctx.cognitive,
1184 ),
1185 ),
1186 }
1187}
1188
1189fn collect_health(env: &Value, out: &mut Vec<Annotation>) {
1190 let ctx = ComplexityThresholds {
1191 cyclomatic: threshold(env, "max_cyclomatic_threshold", "20"),
1192 cognitive: threshold(env, "max_cognitive_threshold", "15"),
1193 crap: threshold(env, "max_crap_threshold", "30"),
1194 };
1195 for finding in arr(env, "findings") {
1196 let severity = finding
1197 .get("severity")
1198 .and_then(Value::as_str)
1199 .unwrap_or("moderate");
1200 let (title, message) = complexity_annotation(finding, &finding_thresholds(finding, &ctx));
1201 push(
1202 out,
1203 complexity_level(finding, severity),
1204 s(finding, "path"),
1205 Anchor::line_col(finding),
1206 title,
1207 message,
1208 );
1209 }
1210 collect_runtime_coverage(env, out);
1211 collect_coverage_intelligence(env, out);
1212 collect_targets(env, out);
1213}
1214
1215fn collect_runtime_coverage(env: &Value, out: &mut Vec<Annotation>) {
1216 let Some(runtime) = env.get("runtime_coverage") else {
1217 return;
1218 };
1219 for finding in arr(runtime, "findings") {
1220 let verdict = s(finding, "verdict");
1221 let level = if verdict == "coverage_unavailable" {
1222 AnnotationLevel::Notice
1223 } else {
1224 AnnotationLevel::Warning
1225 };
1226 let invocations = finding
1227 .get("invocations")
1228 .filter(|value| !value.is_null())
1229 .map_or_else(|| "-".to_owned(), fmt_num);
1230 let evidence = finding.get("evidence").cloned().unwrap_or(Value::Null);
1231 let tracking = evidence
1232 .get("untracked_reason")
1233 .and_then(Value::as_str)
1234 .map_or_else(
1235 || s(&evidence, "v8_tracking").to_owned(),
1236 |reason| format!("{} ({reason})", s(&evidence, "v8_tracking")),
1237 );
1238 let static_status = if evidence
1242 .get("test_only_reference")
1243 .and_then(Value::as_bool)
1244 .unwrap_or(false)
1245 {
1246 format!(
1247 "{} (referenced only from tests)",
1248 s(&evidence, "static_status")
1249 )
1250 } else {
1251 s(&evidence, "static_status").to_owned()
1252 };
1253 let advice = arr(finding, "actions")
1254 .next()
1255 .and_then(|action| action.get("description"))
1256 .and_then(Value::as_str)
1257 .unwrap_or("Review the runtime evidence before changing this path.");
1258 push(
1259 out,
1260 level,
1261 s(finding, "path"),
1262 Anchor::line_only(finding),
1263 format!("Runtime coverage ({verdict})"),
1264 format!(
1265 "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}",
1266 s(finding, "function"),
1267 s(finding, "confidence"),
1268 s(&evidence, "test_coverage"),
1269 ),
1270 );
1271 }
1272}
1273
1274fn collect_coverage_intelligence(env: &Value, out: &mut Vec<Annotation>) {
1275 let Some(intelligence) = env.get("coverage_intelligence") else {
1276 return;
1277 };
1278 for finding in arr(intelligence, "findings") {
1279 let verdict = s(finding, "verdict");
1280 if matches!(verdict, "clean" | "unknown") {
1281 continue;
1282 }
1283 let recommendation = s(finding, "recommendation");
1284 let level = if matches!(verdict, "risky-change-detected" | "high-confidence-delete") {
1285 AnnotationLevel::Error
1286 } else {
1287 AnnotationLevel::Warning
1288 };
1289 let identity = finding
1290 .get("identity")
1291 .and_then(Value::as_str)
1292 .unwrap_or("code");
1293 push(
1294 out,
1295 level,
1296 s(finding, "path"),
1297 Anchor::line_only(finding),
1298 format!("Coverage intelligence ({recommendation})"),
1299 format!("'{identity}' coverage intelligence verdict: {verdict} ({recommendation})"),
1300 );
1301 }
1302}
1303
1304fn collect_targets(env: &Value, out: &mut Vec<Annotation>) {
1305 let targets = env
1306 .get("targets")
1307 .filter(|value| !value.is_null())
1308 .or_else(|| env.get("refactoring_targets"))
1309 .and_then(Value::as_array)
1310 .map(Vec::as_slice)
1311 .unwrap_or_default();
1312 for target in targets.iter().take(5) {
1315 let factors = target
1316 .get("factors")
1317 .and_then(Value::as_array)
1318 .map(|factors| {
1319 factors
1320 .iter()
1321 .map(|factor| {
1322 let detail = factor
1323 .get("detail")
1324 .and_then(Value::as_str)
1325 .map_or_else(|| num(factor, "value"), str::to_owned);
1326 format!(" \u{2022} {}: {detail}", s(factor, "metric"))
1327 })
1328 .collect::<Vec<_>>()
1329 .join("\n")
1330 })
1331 .unwrap_or_default();
1332 push(
1333 out,
1334 AnnotationLevel::Notice,
1335 s(target, "path"),
1336 Anchor::default(),
1337 format!("Refactoring target ({} effort)", s(target, "effort")),
1338 format!(
1339 "Priority: {} | Confidence: {}\n\n{}\n\n{factors}",
1340 s(target, "priority"),
1341 s(target, "confidence"),
1342 s(target, "recommendation"),
1343 ),
1344 );
1345 }
1346}
1347
1348fn collect_security(env: &Value, out: &mut Vec<Annotation>) {
1352 for finding in arr(env, "security_findings") {
1353 let kind = s(finding, "kind");
1354 let severity = finding
1355 .get("severity")
1356 .and_then(Value::as_str)
1357 .unwrap_or("unknown");
1358 let callee = finding
1359 .get("candidate")
1360 .and_then(|candidate| candidate.get("sink"))
1361 .and_then(|sink| sink.get("callee"))
1362 .and_then(Value::as_str)
1363 .filter(|callee| !callee.is_empty())
1364 .unwrap_or("-");
1365 push(
1366 out,
1367 AnnotationLevel::Notice,
1368 s(finding, "path"),
1369 Anchor::line_col(finding),
1370 format!("Security candidate ({kind})"),
1371 format!(
1372 "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.",
1373 s(finding, "evidence"),
1374 ),
1375 );
1376 }
1377}