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