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