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