cuke-dedup 0.3.0

Static analysis for duplicate and reusable Cucumber step definitions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
//! Shared intermediate representation and diagnostics.

use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
/// Source span with one-based starts and one-based, exclusive end coordinates.
pub struct SourceLocation {
    /// Absolute or caller-provided source path.
    pub path: PathBuf,
    /// One-based start line.
    pub line: usize,
    /// One-based start column.
    pub column: usize,
    /// One-based line containing the exclusive end position.
    pub end_line: usize,
    /// One-based, exclusive end column in the original source text.
    pub end_column: usize,
}

impl SourceLocation {
    /// Creates a source location from a path and one-based span coordinates.
    pub fn new(
        path: impl Into<PathBuf>,
        line: usize,
        column: usize,
        end_line: usize,
        end_column: usize,
    ) -> Self {
        Self {
            path: path.into(),
            line,
            column,
            end_line,
            end_column,
        }
    }

    /// Formats the location as `path:line:column`, relative to `root` when possible.
    pub fn display(&self, root: &Path) -> String {
        let path = self.path.strip_prefix(root).unwrap_or(&self.path);
        let path = path.to_string_lossy().replace('\\', "/");
        format!("{path}:{}:{}", self.line, self.column)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Syntax used by a step definition matcher.
#[non_exhaustive]
pub enum MatcherKind {
    /// A Cucumber Expression such as `I have {int} items`.
    CucumberExpression,
    /// A JavaScript regular expression.
    RegularExpression,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Step-definition framework inferred from a source file.
#[non_exhaustive]
pub enum Framework {
    /// Cucumber.js registration APIs.
    CucumberJs,
    /// Playwright BDD registration APIs.
    PlaywrightBdd,
    /// Cypress step definitions registered through the Badeball Cucumber preprocessor.
    CypressCucumber,
    /// No supported framework import could be inferred.
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// Comparable representations of a definition handler.
pub struct HandlerFingerprint {
    /// Exact syntax-tree representation.
    pub exact: String,
    /// Representation normalized for superficial syntax differences.
    pub normalized: String,
    /// Representation with parameter and local identifiers normalized.
    pub alpha_normalized: String,
    /// Control-flow and call structure with literals normalized.
    pub structural: String,
    /// Ordered behavioral operations observed in the handler.
    pub behavior_signature: Vec<String>,
    /// Bounded original source displayed in reports.
    pub source_snippet: String,
    /// Whether a concrete handler body was available for comparison.
    #[serde(default)]
    pub comparable: bool,
    #[serde(default)]
    /// Whether the handler lacks enough behavior for duplication analysis.
    pub trivial: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Rule-scoped, reason-bearing suppression declared beside a definition.
pub struct InlineSuppression {
    /// Rule disabled for findings involving this definition.
    pub rule: Rule,
    /// Human-readable justification retained in reports.
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// A step definition extracted into the analyzer's shared representation.
pub struct StepDefinition {
    /// Original matcher text without JavaScript delimiters.
    pub matcher: String,
    /// Canonical matcher used for equivalence checks.
    pub normalized_matcher: String,
    /// Matcher syntax.
    pub matcher_kind: MatcherKind,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    /// JavaScript regular-expression flags, or an empty string otherwise.
    pub matcher_flags: String,
    /// Comparable handler representations.
    pub handler: HandlerFingerprint,
    /// Framework that registered the definition.
    pub framework: Framework,
    /// Registration name resolved from direct, aliased, or namespaced usage.
    pub registration: String,
    /// Full registration call location.
    pub location: SourceLocation,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    /// Source-local suppression directives attached to this definition.
    pub inline_suppressions: Vec<InlineSuppression>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
/// One concrete step from a parsed Gherkin scenario.
pub struct FeatureStep {
    /// Localized Gherkin keyword.
    pub keyword: String,
    /// Concrete step text after Scenario Outline expansion.
    pub text: String,
    /// Source location of the originating step.
    pub location: SourceLocation,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Stable identifier for an analyzer rule.
#[non_exhaustive]
pub enum Rule {
    /// Two definitions have the same effective matcher.
    DuplicateMatcher,
    /// Two matchers become equivalent after normalization.
    NormalizedMatcher,
    /// A concrete feature step matches multiple definitions.
    AmbiguousStep,
    /// Two matchers can accept the same step text, independent of the feature corpus.
    OverlappingMatcher,
    /// Distinct matchers share an equivalent implementation.
    DuplicateHandler,
    /// Similar wording and handler structure indicate likely duplication.
    NearDuplicateStep,
    /// Similar definitions may be replaceable by a parameterized step.
    ParameterizationCandidate,
    /// No discovered feature step uses a definition.
    UnusedDefinition,
}

impl Rule {
    const DOCUMENTATION_BASE_URL: &'static str =
        "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#";

    /// Every rule in deterministic report order.
    pub const ALL: &'static [Rule] = &[
        Rule::DuplicateMatcher,
        Rule::NormalizedMatcher,
        Rule::AmbiguousStep,
        Rule::OverlappingMatcher,
        Rule::DuplicateHandler,
        Rule::NearDuplicateStep,
        Rule::ParameterizationCandidate,
        Rule::UnusedDefinition,
    ];

    /// Rules whose active error findings contribute to the duplication threshold.
    pub const DUPLICATION_THRESHOLD: &'static [Rule] = &[
        Rule::DuplicateMatcher,
        Rule::NormalizedMatcher,
        Rule::DuplicateHandler,
        Rule::NearDuplicateStep,
        Rule::ParameterizationCandidate,
    ];

    /// Returns the stable kebab-case configuration and report name.
    pub fn as_str(self) -> &'static str {
        match self {
            Rule::DuplicateMatcher => "duplicate-matcher",
            Rule::NormalizedMatcher => "normalized-matcher",
            Rule::AmbiguousStep => "ambiguous-step",
            Rule::OverlappingMatcher => "overlapping-matcher",
            Rule::DuplicateHandler => "duplicate-handler",
            Rule::NearDuplicateStep => "near-duplicate-step",
            Rule::ParameterizationCandidate => "parameterization-candidate",
            Rule::UnusedDefinition => "unused-definition",
        }
    }

    /// Returns whether error findings from this rule contribute to the duplication threshold.
    pub fn contributes_to_duplication_threshold(self) -> bool {
        Self::DUPLICATION_THRESHOLD.contains(&self)
    }

    /// Returns the rule-specific documentation URL used by diagnostics integrations.
    pub fn documentation_url(self) -> String {
        format!("{}{}", Self::DOCUMENTATION_BASE_URL, self.as_str())
    }
}

impl fmt::Display for Rule {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for Rule {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "duplicate-matcher" => Ok(Self::DuplicateMatcher),
            "normalized-matcher" => Ok(Self::NormalizedMatcher),
            "ambiguous-step" => Ok(Self::AmbiguousStep),
            "overlapping-matcher" => Ok(Self::OverlappingMatcher),
            "duplicate-handler" => Ok(Self::DuplicateHandler),
            "near-duplicate-step" => Ok(Self::NearDuplicateStep),
            "parameterization-candidate" => Ok(Self::ParameterizationCandidate),
            "unused-definition" => Ok(Self::UnusedDefinition),
            _ => Err(format!("unknown rule `{value}`")),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
/// Configured impact of a finding.
#[non_exhaustive]
pub enum Severity {
    /// Rule evaluation is disabled.
    Off,
    /// Finding is reported without causing exit code 1.
    Warning,
    /// Active finding causes exit code 1.
    Error,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Severity::Off => f.write_str("off"),
            Severity::Warning => f.write_str("warning"),
            Severity::Error => f.write_str("error"),
        }
    }
}

impl FromStr for Severity {
    type Err = String;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "off" => Ok(Severity::Off),
            "warn" | "warning" => Ok(Severity::Warning),
            "error" => Ok(Severity::Error),
            _ => Err(format!(
                "invalid severity `{value}` (expected off, warning, or error)"
            )),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Human- and machine-readable evidence supporting a finding.
#[non_exhaustive]
pub struct FindingEvidence {
    /// Matcher similarity score from zero to one, when applicable.
    pub matcher_similarity: Option<f64>,
    /// Handler similarity score from zero to one, when applicable.
    pub handler_similarity: Option<f64>,
    /// Concise matcher comparison for terminal output.
    pub matcher_difference: String,
    /// Concise handler comparison for terminal output.
    pub handler_evidence: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    /// Structured definition comparison for rich reports.
    pub comparison: Option<DefinitionComparison>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    /// Connected definition group when a large set of pair findings is collapsed.
    pub cluster: Option<DefinitionCluster>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Stable semantic identity and size information for a clustered finding.
#[non_exhaustive]
pub struct DefinitionCluster {
    /// Total number of definitions represented by the cluster.
    pub member_count: usize,
    /// Location-independent fingerprints for every definition in the cluster.
    ///
    /// Reporters may retain only a bounded prefix and set [`Self::members_truncated`].
    pub definition_fingerprints: Vec<String>,
    /// Number of pair findings represented by the single cluster finding.
    pub pair_findings_collapsed: usize,
    /// Whether a reporter omitted members from its serialized representation.
    pub members_truncated: bool,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Side-by-side matcher and handler content for a pair finding.
#[non_exhaustive]
pub struct DefinitionComparison {
    /// Location-independent semantic fingerprint of the left definition.
    pub left_fingerprint: String,
    /// Location-independent semantic fingerprint of the right definition.
    pub right_fingerprint: String,
    /// Left definition's original matcher.
    pub left_matcher: String,
    /// Right definition's original matcher.
    pub right_matcher: String,
    /// Left definition's bounded handler source.
    pub left_handler: String,
    /// Right definition's bounded handler source.
    pub right_handler: String,
    /// Structured common and changed matcher segments.
    pub matcher_diff: MatcherDiff,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Common and differing segments of two matcher strings.
#[non_exhaustive]
pub struct MatcherDiff {
    /// Shared matcher prefix.
    pub prefix: String,
    /// Segment unique to the left matcher.
    pub left_change: String,
    /// Segment unique to the right matcher.
    pub right_change: String,
    /// Shared matcher suffix.
    pub suffix: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Reason that a finding is excluded from active totals.
#[non_exhaustive]
pub struct Suppression {
    /// Human-readable suppression justification.
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// One rule diagnostic produced by analysis.
#[non_exhaustive]
pub struct Finding {
    /// Rule that produced the finding.
    pub rule: Rule,
    /// Configured impact.
    pub severity: Severity,
    /// Human-readable diagnostic message.
    pub message: String,
    /// Main source span.
    pub primary: SourceLocation,
    /// Additional spans involved in the finding.
    pub related: Vec<SourceLocation>,
    /// Comparison data supporting the diagnostic.
    pub evidence: FindingEvidence,
    /// Recommended remediation.
    pub suggested_action: String,
    /// Suppression metadata, when excluded from active totals.
    pub suppression: Option<Suppression>,
}

impl Finding {
    /// Returns whether the finding contributes to output totals and exit status.
    pub fn is_active(&self) -> bool {
        self.severity != Severity::Off && self.suppression.is_none()
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
/// Extracted inputs and deterministically ordered analyzer findings.
#[non_exhaustive]
pub struct AnalysisResult {
    /// Extracted definition corpus.
    pub definitions: Vec<StepDefinition>,
    /// Concrete feature-step corpus.
    pub feature_steps: Vec<FeatureStep>,
    /// Findings produced by configured rules.
    pub findings: Vec<Finding>,
}

impl AnalysisResult {
    /// Creates a result from extracted inputs and analyzer findings.
    pub fn new(
        definitions: Vec<StepDefinition>,
        feature_steps: Vec<FeatureStep>,
        findings: Vec<Finding>,
    ) -> Self {
        Self {
            definitions,
            feature_steps,
            findings,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
/// Result of applying a percentage-based tolerance to active duplication errors.
#[non_exhaustive]
pub struct DuplicationThreshold {
    /// Maximum duplicated-definition percentage allowed by configuration.
    pub threshold: f64,
    /// Unique definitions involved in active error-level duplication findings.
    pub duplicated_definitions: usize,
    /// Total number of definitions discovered for the analysis.
    pub total_definitions: usize,
    /// Unrounded duplicated-definition percentage.
    pub percentage: f64,
    /// Whether the percentage is less than or equal to the configured threshold.
    pub passed: bool,
    /// Rules whose error-level findings contribute to the numerator.
    pub rules: Vec<Rule>,
}

impl DuplicationThreshold {
    /// Calculates the duplication rate after suppressions and analysis modes are applied.
    pub fn from_result(result: &AnalysisResult, threshold: f64) -> Self {
        let definition_locations: HashSet<_> = result
            .definitions
            .iter()
            .map(|definition| definition.location.clone())
            .collect();
        let mut duplicated_locations = HashSet::new();
        let mut contributing_rules = BTreeSet::new();
        for finding in result.findings.iter().filter(|finding| {
            finding.is_active()
                && finding.severity == Severity::Error
                && finding.rule.contributes_to_duplication_threshold()
        }) {
            contributing_rules.insert(finding.rule);
            for location in std::iter::once(&finding.primary).chain(&finding.related) {
                if definition_locations.contains(location) {
                    duplicated_locations.insert(location.clone());
                }
            }
        }

        let total_definitions = result.definitions.len();
        let duplicated_definitions = result
            .definitions
            .iter()
            .filter(|definition| duplicated_locations.contains(&definition.location))
            .count();
        let percentage = if total_definitions == 0 {
            0.0
        } else {
            duplicated_definitions as f64 / total_definitions as f64 * 100.0
        };
        Self {
            threshold,
            duplicated_definitions,
            total_definitions,
            percentage,
            passed: threshold.is_finite() && percentage <= threshold,
            rules: contributing_rules.into_iter().collect(),
        }
    }
}

/// Computes a deterministic FNV-1a fingerprint for report and comparison data.
pub fn stable_fingerprint(value: &str) -> String {
    // FNV-1a is deliberately simple and deterministic across platforms and Rust versions.
    let mut hash = 0xcbf29ce484222325_u64;
    for byte in value.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    format!("{hash:016x}")
}

pub(crate) fn stable_fingerprint_parts<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
    let mut hash = 0xcbf29ce484222325_u64;
    for (index, part) in parts.into_iter().enumerate() {
        if index > 0 {
            hash ^= 0;
            hash = hash.wrapping_mul(0x100000001b3);
        }
        for byte in part.as_bytes() {
            hash ^= u64::from(*byte);
            hash = hash.wrapping_mul(0x100000001b3);
        }
    }
    format!("{hash:016x}")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn definition(line: usize) -> StepDefinition {
        StepDefinition {
            matcher: format!("step {line}"),
            normalized_matcher: format!("step {line}"),
            matcher_kind: MatcherKind::CucumberExpression,
            matcher_flags: String::new(),
            handler: HandlerFingerprint {
                exact: String::new(),
                normalized: String::new(),
                alpha_normalized: String::new(),
                structural: String::new(),
                behavior_signature: Vec::new(),
                source_snippet: String::new(),
                comparable: false,
                trivial: true,
            },
            framework: Framework::Unknown,
            registration: "Given".to_owned(),
            location: SourceLocation::new("steps.ts", line, 1, line, 10),
            inline_suppressions: Vec::new(),
        }
    }

    fn finding(
        definitions: &[StepDefinition],
        left: usize,
        right: usize,
        severity: Severity,
    ) -> Finding {
        Finding {
            rule: Rule::DuplicateMatcher,
            severity,
            message: "duplicate".to_owned(),
            primary: definitions[left].location.clone(),
            related: vec![definitions[right].location.clone()],
            evidence: FindingEvidence {
                matcher_similarity: Some(1.0),
                handler_similarity: None,
                matcher_difference: String::new(),
                handler_evidence: String::new(),
                comparison: None,
                cluster: None,
            },
            suggested_action: "consolidate".to_owned(),
            suppression: None,
        }
    }

    #[test]
    fn rule_names_are_stable_kebab_case() {
        for &rule in Rule::ALL {
            assert_eq!(rule.to_string().parse::<Rule>(), Ok(rule));
            assert_eq!(
                rule.documentation_url(),
                format!(
                    "https://github.com/figueiredoluiz/cuke-dedup/blob/main/docs/rules.md#{}",
                    rule
                )
            );
        }
    }

    #[test]
    fn multipart_fingerprint_matches_nul_joined_input_without_join_allocation() {
        assert_eq!(
            stable_fingerprint_parts(["duplicate-matcher", "alpha", "bravo"]),
            stable_fingerprint("duplicate-matcher\0alpha\0bravo")
        );
    }

    #[test]
    fn severity_text_forms_are_complete_and_reject_unknown_values() {
        assert_eq!(Severity::Off.to_string(), "off");
        assert_eq!(Severity::Warning.to_string(), "warning");
        assert_eq!(Severity::Error.to_string(), "error");
        assert_eq!("off".parse(), Ok(Severity::Off));
        assert_eq!("warn".parse(), Ok(Severity::Warning));
        assert_eq!("warning".parse(), Ok(Severity::Warning));
        assert_eq!("error".parse(), Ok(Severity::Error));
        assert_eq!(
            "fatal".parse::<Severity>(),
            Err("invalid severity `fatal` (expected off, warning, or error)".to_owned())
        );
    }

    #[test]
    fn threshold_rules_exclude_correctness_and_usage_findings() {
        assert!(Rule::DuplicateMatcher.contributes_to_duplication_threshold());
        assert!(Rule::ParameterizationCandidate.contributes_to_duplication_threshold());
        assert!(!Rule::AmbiguousStep.contributes_to_duplication_threshold());
        assert!(!Rule::UnusedDefinition.contributes_to_duplication_threshold());
    }

    #[test]
    fn duplication_threshold_counts_unique_active_error_definitions() {
        let definitions: Vec<_> = (1..=4).map(definition).collect();
        let first = finding(&definitions, 0, 1, Severity::Error);
        let second = finding(&definitions, 0, 2, Severity::Error);
        let warning = finding(&definitions, 2, 3, Severity::Warning);
        let mut suppressed = finding(&definitions, 1, 3, Severity::Error);
        suppressed.suppression = Some(Suppression {
            reason: "accepted".to_owned(),
        });
        let result = AnalysisResult {
            definitions,
            feature_steps: Vec::new(),
            findings: vec![first, second, warning, suppressed],
        };

        let at_limit = DuplicationThreshold::from_result(&result, 75.0);
        assert_eq!(at_limit.duplicated_definitions, 3);
        assert_eq!(at_limit.total_definitions, 4);
        assert_eq!(at_limit.percentage, 75.0);
        assert_eq!(at_limit.rules, [Rule::DuplicateMatcher]);
        assert!(at_limit.passed);
        assert!(!DuplicationThreshold::from_result(&result, 74.99).passed);
    }

    #[test]
    fn empty_analysis_has_zero_percent_duplication() {
        let result = AnalysisResult::default();
        let threshold = DuplicationThreshold::from_result(&result, 0.0);
        assert_eq!(threshold.percentage, 0.0);
        assert!(threshold.passed);
    }

    #[test]
    fn duplication_threshold_counts_multiple_definitions_at_one_location() {
        let mut definitions = vec![definition(1), definition(2)];
        definitions[1].location = definitions[0].location.clone();
        let duplicate = finding(&definitions, 0, 1, Severity::Error);
        let result = AnalysisResult {
            definitions,
            feature_steps: Vec::new(),
            findings: vec![duplicate],
        };
        let threshold = DuplicationThreshold::from_result(&result, 100.0);
        assert_eq!(threshold.duplicated_definitions, 2);
        assert_eq!(threshold.percentage, 100.0);
    }

    #[test]
    fn fingerprint_is_stable_and_content_sensitive() {
        assert_eq!(stable_fingerprint("hello"), "a430d84680aabd0b");
        assert_ne!(stable_fingerprint("hello"), stable_fingerprint("Hello"));
    }

    #[test]
    fn displayed_locations_use_portable_path_separators() {
        let location = SourceLocation::new("steps\\checkout.ts", 3, 4, 3, 10);
        assert_eq!(location.display(Path::new(".")), "steps/checkout.ts:3:4");
    }
}