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