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