Skip to main content

fallow_api/
dupes_output.rs

1//! Shared duplication JSON payload contracts for programmatic consumers.
2
3use std::path::{Path, PathBuf};
4
5use fallow_engine::duplicates::{
6    CloneFingerprintSet, clone_fingerprint, dominant_identifier, fingerprint_for_fragment,
7};
8use fallow_output::{
9    CloneFamilyAction, CloneGroupAction, CodeClimateIssue, CodeClimateIssueInput,
10    CodeClimateSeverity, clone_family_actions, clone_group_actions, codeclimate_fingerprint_hash,
11    normalize_uri,
12};
13use fallow_types::duplicates::{
14    CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, MirroredDirectory,
15    RefactoringSuggestion,
16};
17use fallow_types::envelope::AuditIntroduced;
18use fallow_types::serde_path;
19use serde::Serialize;
20
21/// A clone instance plus its per-instance owner key (for inline JSON / SARIF
22/// rendering).
23///
24/// Each instance carries its own `owner` field alongside the standard
25/// `CloneInstance` shape (file / start_line / end_line / start_col / end_col /
26/// fragment), so consumers can attribute instances to resolver keys without
27/// re-resolving paths.
28#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct AttributedInstance {
31    /// The original clone instance.
32    #[serde(flatten)]
33    pub instance: CloneInstance,
34    /// Resolver key for this specific instance (per-instance, not the
35    /// group-level largest-owner).
36    pub owner: String,
37}
38
39/// A clone group annotated with largest-owner attribution and per-instance
40/// owner keys.
41#[derive(Debug, Clone, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct AttributedCloneGroup {
44    /// Largest-owner attribution: the resolver key with the most instances in
45    /// this clone group. Ties broken alphabetically (smallest key wins).
46    pub primary_owner: String,
47    /// Number of tokens in the clone group.
48    pub token_count: usize,
49    /// Number of source lines in the clone group.
50    pub line_count: usize,
51    /// Each instance carries its own `owner` field alongside the standard
52    /// CloneInstance shape.
53    pub instances: Vec<AttributedInstance>,
54}
55
56impl AttributedCloneGroup {
57    /// Return the report-scoped fingerprint for this attributed group.
58    #[must_use]
59    pub fn fingerprint(&self, fingerprints: &CloneFingerprintSet) -> String {
60        let instances: Vec<_> = self
61            .instances
62            .iter()
63            .map(|instance| instance.instance.clone())
64            .collect();
65        fingerprints.fingerprint_for_parts(&instances, self.token_count, self.line_count)
66    }
67}
68
69/// Wire-shape envelope for an [`AttributedCloneGroup`] finding (per-bucket
70/// duplication attribution emitted under `fallow dupes --group-by`).
71/// Flattens the attributed group and carries the same typed
72/// `CloneGroupAction` array as `CloneGroupFinding`; no `introduced`
73/// field because `fallow audit` does not run on grouped output.
74#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct AttributedCloneGroupFinding {
77    /// The underlying attributed clone group.
78    #[serde(flatten)]
79    pub group: AttributedCloneGroup,
80    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
81    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>`.
82    /// Computed from the group's instances, so it matches the top-level
83    /// `clone_groups[].fingerprint` for the same clone.
84    pub fingerprint: String,
85    /// Suggested next steps. Always emitted.
86    pub actions: Vec<CloneGroupAction>,
87}
88
89impl AttributedCloneGroupFinding {
90    /// Build the wrapper from an [`AttributedCloneGroup`].
91    #[allow(
92        dead_code,
93        reason = "kept for focused wrapper tests and non-report construction paths"
94    )]
95    #[must_use]
96    pub fn with_actions(group: AttributedCloneGroup) -> Self {
97        let fingerprint = group.instances.first().map_or_else(
98            || fingerprint_for_fragment(""),
99            |ai| fingerprint_for_fragment(&ai.instance.fragment),
100        );
101        Self::with_fingerprint(group, fingerprint)
102    }
103
104    /// Build the wrapper with a precomputed report-scoped fingerprint.
105    #[must_use]
106    pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
107        let actions = clone_group_actions(group.line_count, group.instances.len());
108        Self {
109            group,
110            fingerprint,
111            actions,
112        }
113    }
114}
115
116/// A single grouped duplication bucket. Per-group `stats` are dedup-aware and
117/// computed over the FULL group BEFORE any `--top` truncation.
118#[derive(Debug, Clone, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct DuplicationGroup {
121    /// Group label (owner / directory / package / section). `(unowned)` for
122    /// files with no CODEOWNERS rule, `(no section)` for pre-section rules in
123    /// section mode.
124    pub key: String,
125    /// Dedup-aware aggregate stats for the group.
126    pub stats: DuplicationStats,
127    /// Clone groups attributed to this owner, each wrapped with the typed
128    /// `actions[]` array. Each group's `primary_owner` is its largest-owner
129    /// key; per-instance `owner` lets consumers see cross-bucket fan-out
130    /// without re-resolving paths.
131    pub clone_groups: Vec<AttributedCloneGroupFinding>,
132    /// Clone families overlapping this bucket, each wrapped with the typed
133    /// `actions[]` array.
134    pub clone_families: Vec<CloneFamilyFinding>,
135}
136
137/// Wrapper carrying the resolver mode label and grouped buckets.
138#[derive(Debug, Clone, Serialize)]
139pub struct DuplicationGrouping {
140    /// Resolver mode label (`"owner"`, `"directory"`, `"package"`, `"section"`).
141    pub mode: &'static str,
142    /// One bucket per resolver key.
143    pub groups: Vec<DuplicationGroup>,
144}
145
146/// Wire-shape envelope for a [`CloneGroup`] finding. Flattens the bare
147/// group via `#[serde(flatten)]` and carries a typed `actions` array plus
148/// the optional audit-mode `introduced` flag. The typed envelope replaced
149/// the legacy JSON post-pass injection; a guard test in
150/// `crates/cli/src/report/json.rs` rejects any reintroduced post-pass.
151#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct CloneGroupFinding {
154    /// The underlying clone group.
155    #[serde(flatten)]
156    pub group: CloneGroup,
157    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
158    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>` (and
159    /// the `trace_clone` MCP tool) to deep-dive this group; shown alongside
160    /// each group in the human listing.
161    pub fingerprint: String,
162    /// Best-effort human-readable name for the clone: the dominant repeated
163    /// identifier across the duplicated fragment (e.g. a shared `parseCsv`
164    /// function). `None` when the clone has no clear dominant name (generic or
165    /// tied identifiers); consumers then fall back to a file-based label. Lets
166    /// editors and agents label a clone by what it is rather than an opaque
167    /// ordinal.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub suggested_name: Option<String>,
170    /// Suggested next steps: an `extract-shared` primary and a
171    /// `suppress-line` secondary. Always emitted (possibly empty for
172    /// forward-compat).
173    pub actions: Vec<CloneGroupAction>,
174    /// Set by the audit pass when this clone group is introduced relative
175    /// to the merge-base. `None` when serialized directly from Rust.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub introduced: Option<AuditIntroduced>,
178}
179
180impl CloneGroupFinding {
181    /// Build the wrapper from a raw [`CloneGroup`].
182    #[allow(
183        dead_code,
184        reason = "kept for focused wrapper tests and non-report construction paths"
185    )]
186    #[must_use]
187    pub fn with_actions(group: CloneGroup) -> Self {
188        let fingerprint = clone_fingerprint(&group.instances);
189        Self::with_fingerprint(group, fingerprint)
190    }
191
192    /// Build the wrapper with a precomputed report-scoped fingerprint.
193    #[must_use]
194    pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
195        let suggested_name = dominant_identifier(&group);
196        let actions = clone_group_actions(group.line_count, group.instances.len());
197        Self {
198            fingerprint,
199            suggested_name,
200            group,
201            actions,
202            introduced: None,
203        }
204    }
205}
206
207/// Wire-shape envelope for a [`CloneFamily`] finding.
208///
209/// Unlike most `*Finding` wrappers this one is NOT `#[serde(flatten)]` over
210/// the bare [`CloneFamily`], because the family's nested
211/// `groups: Vec<CloneGroup>` field needs to carry the typed
212/// `CloneGroupFinding` wrapper too (so every nested clone group gets its
213/// own `actions[]` array, matching the legacy post-pass behavior; see issue
214/// #393 regression test). The wire shape stays byte-identical to the
215/// previous post-pass output. No `introduced` field because `fallow audit`
216/// attributes clone groups (not families) when running against a base ref.
217#[derive(Debug, Clone, Serialize)]
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219pub struct CloneFamilyFinding {
220    /// The files involved in this family.
221    #[serde(serialize_with = "serde_path::serialize_vec")]
222    pub files: Vec<PathBuf>,
223    /// Clone groups belonging to this family, each wrapped with typed
224    /// `actions[]` so consumers that read `clone_families[].groups[]`
225    /// directly see the same shape as the top-level `clone_groups[]`.
226    pub groups: Vec<CloneGroupFinding>,
227    /// Total number of duplicated lines across all groups.
228    pub total_duplicated_lines: usize,
229    /// Total number of duplicated tokens across all groups.
230    pub total_duplicated_tokens: usize,
231    /// Refactoring suggestions for this family.
232    pub suggestions: Vec<RefactoringSuggestion>,
233    /// Suggested next steps: an `extract-shared` primary, one
234    /// `apply-suggestion` per `RefactoringSuggestion` on the family, and
235    /// a trailing `suppress-line`. Always emitted (possibly empty for
236    /// forward-compat).
237    pub actions: Vec<CloneFamilyAction>,
238}
239
240impl CloneFamilyFinding {
241    /// Build the wrapper from a raw [`CloneFamily`].
242    #[allow(
243        dead_code,
244        reason = "kept for focused wrapper tests and non-report construction paths"
245    )]
246    #[must_use]
247    pub fn with_actions(family: CloneFamily) -> Self {
248        let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
249        Self::with_fingerprints(family, &fingerprints)
250    }
251
252    /// Build the wrapper using the report-scoped fingerprint assignment shared
253    /// by all duplication output surfaces.
254    #[must_use]
255    pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
256        let actions = build_clone_family_actions(
257            &family.groups,
258            family.total_duplicated_lines,
259            &family.suggestions,
260        );
261        Self {
262            files: family.files,
263            groups: family
264                .groups
265                .into_iter()
266                .map(|group| {
267                    let fingerprint = fingerprints.fingerprint_for_group(&group);
268                    CloneGroupFinding::with_fingerprint(group, fingerprint)
269                })
270                .collect(),
271            total_duplicated_lines: family.total_duplicated_lines,
272            total_duplicated_tokens: family.total_duplicated_tokens,
273            suggestions: family.suggestions,
274            actions,
275        }
276    }
277}
278
279fn build_clone_family_actions(
280    groups: &[CloneGroup],
281    total_duplicated_lines: usize,
282    suggestions: &[RefactoringSuggestion],
283) -> Vec<CloneFamilyAction> {
284    clone_family_actions(
285        groups.len(),
286        total_duplicated_lines,
287        suggestions
288            .iter()
289            .map(|suggestion| suggestion.description.as_str()),
290    )
291}
292
293/// Wire-shape payload for `fallow dupes --format json` (the body that
294/// flattens into the `DupesOutput` envelope and is also
295/// emitted under the `dupes` / `duplication` key inside the combined and
296/// audit envelopes).
297///
298/// Mirrors [`DuplicationReport`] field-for-field, except `clone_groups`
299/// and `clone_families` carry the typed wrapper envelopes instead of bare
300/// findings, so the schema (and any TS / agent consumer) sees the typed
301/// `actions[]` natively.
302#[derive(Debug, Clone, Serialize)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304pub struct DupesReportPayload {
305    /// All detected clone groups, each wrapped with typed actions.
306    pub clone_groups: Vec<CloneGroupFinding>,
307    /// Clone families, each wrapped with typed actions. Inner `groups`
308    /// inside each `CloneFamilyFinding` are themselves wrapped as
309    /// `CloneGroupFinding` entries carrying their own `actions[]` (and
310    /// optional audit-mode `introduced` flag), so JSON-Schema strict
311    /// consumers and TS consumers reading `clone_families[].groups[]` see
312    /// the same shape as the top-level `clone_groups[]` array (preserves
313    /// the issue #393 regression contract).
314    pub clone_families: Vec<CloneFamilyFinding>,
315    /// Mirrored directory pairs.
316    #[serde(default, skip_serializing_if = "Vec::is_empty")]
317    pub mirrored_directories: Vec<MirroredDirectory>,
318    /// Aggregate duplication statistics.
319    pub stats: DuplicationStats,
320}
321
322impl DupesReportPayload {
323    /// Build the payload from a bare [`DuplicationReport`].
324    #[must_use]
325    pub fn from_report(report: &DuplicationReport) -> Self {
326        let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
327        Self {
328            clone_groups: report
329                .clone_groups
330                .iter()
331                .map(|group| {
332                    CloneGroupFinding::with_fingerprint(
333                        group.clone(),
334                        fingerprints.fingerprint_for_group(group),
335                    )
336                })
337                .collect(),
338            clone_families: report
339                .clone_families
340                .iter()
341                .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
342                .collect(),
343            mirrored_directories: report.mirrored_directories.clone(),
344            stats: report.stats.clone(),
345        }
346    }
347}
348
349/// Build CodeClimate issues from duplication analysis results.
350///
351/// `fallow-output` owns the CodeClimate wire DTOs. This API layer combines
352/// those DTOs with the engine-owned duplication report so CLI and future
353/// embedders can share the same issue construction policy.
354#[must_use]
355#[expect(
356    clippy::cast_possible_truncation,
357    reason = "line numbers are bounded by source size"
358)]
359pub fn build_duplication_codeclimate(
360    report: &DuplicationReport,
361    root: &Path,
362) -> Vec<CodeClimateIssue> {
363    let mut issues = Vec::new();
364
365    for (i, group) in report.clone_groups.iter().enumerate() {
366        let token_str = group.token_count.to_string();
367        let line_count_str = group.line_count.to_string();
368        let fragment_prefix: String = group
369            .instances
370            .first()
371            .map(|inst| inst.fragment.chars().take(64).collect())
372            .unwrap_or_default();
373
374        for instance in &group.instances {
375            let path = codeclimate_path(&instance.file, root);
376            let start_str = instance.start_line.to_string();
377            let fp = codeclimate_fingerprint_hash(&[
378                "fallow/code-duplication",
379                &path,
380                &start_str,
381                &token_str,
382                &line_count_str,
383                &fragment_prefix,
384            ]);
385            issues.push(fallow_output::build_codeclimate_issue(
386                CodeClimateIssueInput {
387                    check_name: "fallow/code-duplication",
388                    description: &format!(
389                        "Code clone group {} ({} lines, {} instances)",
390                        i + 1,
391                        group.line_count,
392                        group.instances.len()
393                    ),
394                    severity: CodeClimateSeverity::Minor,
395                    category: "Duplication",
396                    path: &path,
397                    begin_line: Some(instance.start_line as u32),
398                    fingerprint: &fp,
399                },
400            ));
401        }
402    }
403
404    issues
405}
406
407fn codeclimate_path(path: &Path, root: &Path) -> String {
408    normalize_uri(
409        &path
410            .strip_prefix(root)
411            .unwrap_or(path)
412            .display()
413            .to_string(),
414    )
415}
416
417#[cfg(test)]
418mod tests {
419    use std::path::Path;
420
421    use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
422    use fallow_types::duplicates::{
423        CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
424    };
425
426    use super::*;
427
428    fn instance(path: &str) -> CloneInstance {
429        CloneInstance {
430            file: PathBuf::from(path),
431            start_line: 1,
432            end_line: 10,
433            start_col: 0,
434            end_col: 0,
435            fragment: String::new(),
436        }
437    }
438
439    fn group(instances: usize) -> CloneGroup {
440        CloneGroup {
441            instances: (0..instances)
442                .map(|i| instance(&format!("/root/file_{i}.ts")))
443                .collect(),
444            token_count: 100,
445            line_count: 20,
446        }
447    }
448
449    #[test]
450    fn clone_group_finding_position_0_is_extract_shared() {
451        let finding = CloneGroupFinding::with_actions(group(2));
452        assert_eq!(finding.actions.len(), 2);
453        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
454        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
455        assert!(finding.introduced.is_none());
456    }
457
458    #[test]
459    fn attributed_clone_group_finding_actions_match_clone_group_shape() {
460        let attributed = AttributedCloneGroup {
461            primary_owner: "src".to_string(),
462            token_count: 100,
463            line_count: 20,
464            instances: vec![
465                AttributedInstance {
466                    instance: instance("/root/src/a.ts"),
467                    owner: "src".to_string(),
468                },
469                AttributedInstance {
470                    instance: instance("/root/src/b.ts"),
471                    owner: "src".to_string(),
472                },
473            ],
474        };
475        let finding = AttributedCloneGroupFinding::with_actions(attributed);
476        assert_eq!(finding.actions.len(), 2);
477        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
478        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
479    }
480
481    #[test]
482    fn clone_group_finding_surfaces_dominant_identifier() {
483        let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
484        let g = CloneGroup {
485            instances: vec![
486                CloneInstance {
487                    file: PathBuf::from("/root/a.ts"),
488                    start_line: 1,
489                    end_line: 3,
490                    start_col: 0,
491                    end_col: 0,
492                    fragment: fragment.to_string(),
493                },
494                CloneInstance {
495                    file: PathBuf::from("/root/b.ts"),
496                    start_line: 1,
497                    end_line: 3,
498                    start_col: 0,
499                    end_col: 0,
500                    fragment: fragment.to_string(),
501                },
502            ],
503            token_count: 100,
504            line_count: 3,
505        };
506        let finding = CloneGroupFinding::with_actions(g);
507        assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
508    }
509
510    #[test]
511    fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
512        let finding = CloneGroupFinding::with_actions(group(2));
513        assert!(finding.suggested_name.is_none());
514    }
515
516    #[test]
517    fn clone_group_finding_description_pluralises_instance_count() {
518        let single = CloneGroupFinding::with_actions(group(1));
519        assert!(single.actions[0].description.contains("1 instance"));
520        assert!(!single.actions[0].description.contains("1 instances"));
521        let multi = CloneGroupFinding::with_actions(group(3));
522        assert!(multi.actions[0].description.contains("3 instances"));
523    }
524
525    #[test]
526    fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
527        let family = CloneFamily {
528            files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
529            groups: vec![group(2), group(2)],
530            total_duplicated_lines: 40,
531            total_duplicated_tokens: 200,
532            suggestions: vec![
533                RefactoringSuggestion {
534                    kind: RefactoringKind::ExtractFunction,
535                    description: "Extract helper".to_string(),
536                    estimated_savings: 10,
537                },
538                RefactoringSuggestion {
539                    kind: RefactoringKind::ExtractModule,
540                    description: "Extract module".to_string(),
541                    estimated_savings: 30,
542                },
543            ],
544        };
545        let finding = CloneFamilyFinding::with_actions(family);
546        assert_eq!(finding.actions.len(), 4);
547        assert_eq!(
548            finding.actions[0].kind,
549            CloneFamilyActionType::ExtractShared
550        );
551        assert_eq!(
552            finding.actions[1].kind,
553            CloneFamilyActionType::ApplySuggestion
554        );
555        assert_eq!(finding.actions[1].description, "Extract helper");
556        assert_eq!(
557            finding.actions[2].kind,
558            CloneFamilyActionType::ApplySuggestion
559        );
560        assert_eq!(finding.actions[2].description, "Extract module");
561        assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
562        assert_eq!(finding.groups.len(), 2);
563        for inner in &finding.groups {
564            assert_eq!(inner.actions.len(), 2);
565            assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
566            assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
567        }
568    }
569
570    #[test]
571    fn clone_family_finding_with_no_suggestions_emits_two_actions() {
572        let family = CloneFamily {
573            files: vec![PathBuf::from("/root/a.ts")],
574            groups: vec![group(2)],
575            total_duplicated_lines: 20,
576            total_duplicated_tokens: 100,
577            suggestions: Vec::new(),
578        };
579        let finding = CloneFamilyFinding::with_actions(family);
580        assert_eq!(finding.actions.len(), 2);
581        assert_eq!(
582            finding.actions[0].kind,
583            CloneFamilyActionType::ExtractShared
584        );
585        assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
586    }
587
588    #[test]
589    fn payload_from_report_wraps_all_findings() {
590        let report = DuplicationReport {
591            clone_groups: vec![group(2), group(3)],
592            clone_families: vec![CloneFamily {
593                files: vec![PathBuf::from("/root/a.ts")],
594                groups: vec![group(2)],
595                total_duplicated_lines: 20,
596                total_duplicated_tokens: 100,
597                suggestions: Vec::new(),
598            }],
599            mirrored_directories: Vec::new(),
600            stats: DuplicationStats::default(),
601        };
602        let payload = DupesReportPayload::from_report(&report);
603        assert_eq!(payload.clone_groups.len(), 2);
604        assert_eq!(payload.clone_families.len(), 1);
605        for finding in &payload.clone_groups {
606            assert_eq!(finding.actions.len(), 2);
607        }
608        assert_eq!(payload.clone_families[0].actions.len(), 2);
609    }
610
611    #[test]
612    fn duplication_codeclimate_uses_relative_normalized_paths() {
613        let report = DuplicationReport {
614            clone_groups: vec![CloneGroup {
615                instances: vec![CloneInstance {
616                    file: PathBuf::from("/root/app/[id]/page.tsx"),
617                    start_line: 4,
618                    end_line: 8,
619                    start_col: 0,
620                    end_col: 0,
621                    fragment: "const duplicate = 1;".to_string(),
622                }],
623                token_count: 42,
624                line_count: 5,
625            }],
626            clone_families: Vec::new(),
627            mirrored_directories: Vec::new(),
628            stats: DuplicationStats::default(),
629        };
630
631        let issues = build_duplication_codeclimate(&report, Path::new("/root"));
632
633        assert_eq!(issues.len(), 1);
634        let issue = &issues[0];
635        assert_eq!(issue.check_name, "fallow/code-duplication");
636        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
637        assert_eq!(issue.location.lines.begin, 4);
638        assert_eq!(issue.categories, vec!["Duplication"]);
639        assert!(issue.description.contains("Code clone group 1"));
640    }
641}