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