Skip to main content

fallow_types/
identity.rs

1//! Stable public identity for findings.
2//!
3//! This module owns the FNV-1a 64 hash that CodeClimate fingerprints, SARIF
4//! fingerprints and security ids use, and the `finding_id` of dead-code
5//! findings. One implementation keeps the surfaces from drifting apart.
6//!
7//! A dead-code id has the form `dc1:<rule>:<16 hex digits>`. The hash input is
8//! `["dc1", rule, parts...]`, where the parts name the subject of the finding:
9//! root-relative forward-slash paths and raw symbol names. Line and column are
10//! never parts, so a line shift, a reformat or a reorder keeps the id. A
11//! rename of the file or the symbol, or another issue type, gives a new id.
12//!
13//! When several findings of one type have the same parts, they are sorted by
14//! line, column, span start and serialized finding. The first one keeps the
15//! base id. The finding at sorted position `k` gets the suffix `~k`.
16//!
17//! The canonical key is the readable form of the same input:
18//! `<rule>:<part>:<part>...`, for example `unused-export:src/utils.ts:helper`.
19//! Baselines and the audit new-only gate compare findings by this key, so the
20//! id, the baseline and the audit can never disagree on what one finding is.
21//! The key has no tiebreak suffix: a baseline stores one key for each
22//! occurrence, and the audit numbers repeated keys itself.
23//!
24//! [`stamp_dead_code_finding_ids`](crate::identity::stamp_dead_code_finding_ids) writes the ids onto a full result set. The
25//! analysis pipeline calls it before the workspace, scope, changed-file,
26//! ignore, baseline and rule filters, so a filter never changes the id of a
27//! finding that stays in the report.
28
29use std::path::Path;
30
31use rustc_hash::{FxHashMap, FxHashSet};
32use serde::Serialize;
33
34use crate::discover::StableFileKey;
35use crate::output_dead_code::{
36    BoundaryCallViolationFinding, BoundaryCoverageViolationFinding, BoundaryViolationFinding,
37    CircularDependencyFinding, DeprecatedExportInUseFinding, DevDependencyInProductionFinding,
38    DuplicateExportFinding, DuplicatePropShapeFinding, DynamicSegmentNameConflictFinding,
39    EmptyCatalogGroupFinding, InvalidClientExportFinding, MisconfiguredDependencyOverrideFinding,
40    MisplacedDirectiveFinding, MixedClientServerBarrelFinding, PackageCycleFinding,
41    PolicyViolationFinding, PrivateTypeLeakFinding, PropDrillingChainFinding, ReExportCycleFinding,
42    RouteCollisionFinding, TestOnlyDependencyFinding, ThinWrapperFinding,
43    TypeOnlyDependencyFinding, UnlistedDependencyFinding, UnprovidedInjectFinding,
44    UnrenderedComponentFinding, UnresolvedCatalogReferenceFinding, UnresolvedImportFinding,
45    UnusedCatalogEntryFinding, UnusedClassMemberFinding, UnusedComponentEmitFinding,
46    UnusedComponentInputFinding, UnusedComponentOutputFinding, UnusedComponentPropFinding,
47    UnusedDependencyFinding, UnusedDependencyOverrideFinding, UnusedDevDependencyFinding,
48    UnusedEnumMemberFinding, UnusedExportFinding, UnusedFileFinding, UnusedLoadDataKeyFinding,
49    UnusedOptionalDependencyFinding, UnusedServerActionFinding, UnusedStoreMemberFinding,
50    UnusedSvelteEventFinding, UnusedTypeFinding,
51};
52use crate::results::{
53    AnalysisResults, DependencyOverrideSource, ReExportCycleKind, StaleSuppression,
54    SuppressionOrigin,
55};
56
57/// The version prefix of every dead-code finding id. A change to the hash
58/// inputs moves this prefix, so an old id never matches a new finding.
59pub const DEAD_CODE_ID_SCHEME: &str = "dc1";
60
61const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
62const FNV_PRIME: u64 = 0x0100_0000_01b3;
63/// Written after each part. No UTF-8 string contains this byte, so the parts
64/// `["ab", "c"]` and `["a", "bc"]` give different hashes.
65const PART_SEPARATOR: u8 = 0xff;
66/// Joins the sorted members of a path set into one part.
67const SET_SEPARATOR: &str = "|";
68/// Stands for "every issue kind" in a suppression identity.
69const ANY_KIND: &str = "*";
70
71fn fnv1a64_update(mut hash: u64, bytes: &[u8]) -> u64 {
72    for byte in bytes {
73        hash ^= u64::from(*byte);
74        hash = hash.wrapping_mul(FNV_PRIME);
75    }
76    hash
77}
78
79/// FNV-1a 64 of `bytes`, as 16 lowercase hex digits.
80///
81/// Security ids use this form: one string, no part separators.
82#[must_use]
83pub fn fnv1a64_hex(bytes: &[u8]) -> String {
84    format!("{:016x}", fnv1a64_update(FNV_OFFSET_BASIS, bytes))
85}
86
87/// FNV-1a 64 of `parts`, with the byte `0xff` after each part, as 16
88/// lowercase hex digits.
89///
90/// CodeClimate and SARIF fingerprints and dead-code finding ids use this form.
91/// FNV-1a is used because its output is fixed across Rust versions, which is
92/// not true for `DefaultHasher`.
93#[must_use]
94pub fn fnv1a64_parts(parts: &[&str]) -> String {
95    let hash = parts.iter().fold(FNV_OFFSET_BASIS, |hash, part| {
96        fnv1a64_update(fnv1a64_update(hash, part.as_bytes()), &[PART_SEPARATOR])
97    });
98    format!("{hash:016x}")
99}
100
101/// The base id of a dead-code finding: `dc1:<rule_token>:<hash>`.
102///
103/// `rule_token` is the canonical issue code, for example `unused-export`.
104/// `parts` name the subject of the finding and never contain a line or a
105/// column. The id carries no tiebreak suffix; [`stamp_dead_code_finding_ids`]
106/// adds it when two findings share a base id.
107#[must_use]
108pub fn dead_code_finding_id(rule_token: &str, parts: &[&str]) -> String {
109    let mut input = Vec::with_capacity(parts.len() + 2);
110    input.push(DEAD_CODE_ID_SCHEME);
111    input.push(rule_token);
112    input.extend_from_slice(parts);
113    format!(
114        "{DEAD_CODE_ID_SCHEME}:{rule_token}:{}",
115        fnv1a64_parts(&input)
116    )
117}
118
119/// Joins the rule token and the parts of a canonical key.
120const KEY_SEPARATOR: char = ':';
121/// Starts the occurrence suffix of an audit key, as in a finding id.
122const OCCURRENCE_MARKER: char = '~';
123
124/// Escape `%` and `:` in one part, so the joined key splits back into the
125/// same parts. Other characters stay as they are, so the key stays readable.
126fn escape_key_part(part: &str, key: &mut String) {
127    for character in part.chars() {
128        match character {
129            '%' => key.push_str("%25"),
130            KEY_SEPARATOR => key.push_str("%3A"),
131            other => key.push(other),
132        }
133    }
134}
135
136/// The canonical key of a dead-code finding: `<rule_token>:<part>:<part>...`.
137///
138/// The key holds the same input as [`dead_code_finding_id`], in readable
139/// form. Each part escapes `%` as `%25` and `:` as `%3A`. The key never
140/// holds a line, a column or a tiebreak suffix.
141#[must_use]
142pub fn dead_code_canonical_key(rule_token: &str, parts: &[&str]) -> String {
143    let mut key = String::with_capacity(
144        rule_token.len() + parts.iter().map(|part| part.len() + 1).sum::<usize>(),
145    );
146    key.push_str(rule_token);
147    for part in parts {
148        key.push(KEY_SEPARATOR);
149        escape_key_part(part, &mut key);
150    }
151    key
152}
153
154/// The canonical keys of `findings`, in input order, with an occurrence
155/// suffix on repeated keys.
156///
157/// The first finding with a key gets the plain key. The finding at
158/// occurrence `k` (counted from 0, in input order) gets the extra part `~k`.
159/// Two key sets built this way compare by count: when the base has two
160/// occurrences and the head has three, only the third head key is absent
161/// from the base.
162#[must_use]
163pub fn dead_code_occurrence_keys<T: IdentifiedFinding>(
164    findings: &[T],
165    paths: &IdentityPaths<'_>,
166) -> Vec<String> {
167    let mut seen: FxHashMap<String, usize> = FxHashMap::default();
168    findings
169        .iter()
170        .map(|finding| {
171            let key = finding.canonical_key(paths);
172            let occurrence = seen.entry(key.clone()).or_default();
173            let numbered = if *occurrence == 0 {
174                key
175            } else {
176                format!("{key}{KEY_SEPARATOR}{OCCURRENCE_MARKER}{occurrence}")
177            };
178            *occurrence += 1;
179            numbered
180        })
181        .collect()
182}
183
184/// Turns finding paths into identity parts.
185#[derive(Debug, Clone, Copy)]
186pub struct IdentityPaths<'a> {
187    root: &'a Path,
188}
189
190impl<'a> IdentityPaths<'a> {
191    /// Paths under `root` become root-relative. Other paths stay as they are.
192    #[must_use]
193    pub const fn new(root: &'a Path) -> Self {
194        Self { root }
195    }
196
197    /// The root-relative path with forward slashes.
198    #[must_use]
199    pub fn key(&self, path: &Path) -> String {
200        StableFileKey::from_root_relative(self.root, path)
201            .as_str()
202            .to_owned()
203    }
204
205    /// The sorted, unique keys of `paths`, joined by `|`.
206    ///
207    /// Each key escapes `%` as `%25` and `|` as `%7C` before the join, so a
208    /// file name that contains `|` cannot give the same part as two files.
209    /// A key without these characters does not change.
210    #[must_use]
211    pub fn set<'p>(&self, paths: impl IntoIterator<Item = &'p Path>) -> String {
212        let mut keys: Vec<String> = paths
213            .into_iter()
214            .map(|path| escape_set_member(&self.key(path)))
215            .collect();
216        keys.sort_unstable();
217        keys.dedup();
218        keys.join(SET_SEPARATOR)
219    }
220}
221
222/// Escape the escape character first, then the separator.
223fn escape_set_member(key: &str) -> String {
224    if !key.contains(['%', '|']) {
225        return key.to_owned();
226    }
227    key.replace('%', "%25").replace('|', "%7C")
228}
229
230/// A dead-code finding that carries a stable `finding_id`.
231pub trait IdentifiedFinding: Serialize {
232    /// The canonical issue code of this finding.
233    fn rule_token(&self) -> &'static str;
234
235    /// The parts that name the subject of this finding. Never a line or a
236    /// column.
237    fn identity_parts(&self, paths: &IdentityPaths<'_>) -> Vec<String>;
238
239    /// The canonical key of this finding: the readable form of the id input.
240    /// See [`dead_code_canonical_key`].
241    fn canonical_key(&self, paths: &IdentityPaths<'_>) -> String {
242        let parts = self.identity_parts(paths);
243        let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
244        dead_code_canonical_key(self.rule_token(), &parts)
245    }
246
247    /// Line, column and span start. Used only to order findings that share
248    /// a base id.
249    fn tiebreak_position(&self) -> (u32, u32, u32);
250
251    /// The stamped id, or `None` before the stamping pass.
252    fn finding_id(&self) -> Option<&str>;
253
254    /// Write the id.
255    fn set_finding_id(&mut self, id: Option<String>);
256}
257
258/// How a pass treats ids that a finding already carries.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260enum StampMode {
261    /// Compute every id again from the full set.
262    All,
263    /// Keep every existing id and give an id only to findings without one.
264    Missing,
265}
266
267/// Write a `finding_id` onto every dead-code finding in `results`.
268///
269/// `root` is the project root that makes paths root-relative. The function
270/// overwrites earlier values, so a second call on the same set gives the same
271/// ids. Call it on the full result set, before any filter removes findings:
272/// the tiebreak suffix depends on the other findings with the same base id.
273pub fn stamp_dead_code_finding_ids(results: &mut AnalysisResults, root: &Path) {
274    visit_families(
275        results,
276        &mut StampPass {
277            paths: IdentityPaths::new(root),
278            mode: StampMode::All,
279        },
280    );
281}
282
283/// Give an id to each dead-code finding in `results` that has none, and keep
284/// every existing id.
285///
286/// A stage that adds findings after the scope filters (type-aware refinement)
287/// calls this. A full restamp there would compute tiebreak suffixes over a
288/// filtered set and change the id of a kept finding. A new finding whose base
289/// id is taken gets the lowest free `~k` suffix.
290pub fn stamp_missing_dead_code_finding_ids(results: &mut AnalysisResults, root: &Path) {
291    visit_families(
292        results,
293        &mut StampPass {
294            paths: IdentityPaths::new(root),
295            mode: StampMode::Missing,
296        },
297    );
298}
299
300/// Keep only the dead-code findings whose `finding_id` is in `ids`, and
301/// return the ids that matched a finding.
302///
303/// A finding without an id is removed. Fields that are not findings (entry
304/// point summary, feature flags, export usages) stay as they are.
305#[expect(
306    clippy::implicit_hasher,
307    reason = "fallow standardizes on FxHashSet across the workspace"
308)]
309pub fn retain_dead_code_findings_by_id(
310    results: &mut AnalysisResults,
311    ids: &FxHashSet<String>,
312) -> FxHashSet<String> {
313    let mut pass = RetainPass {
314        ids,
315        matched: FxHashSet::default(),
316        keep_all: false,
317    };
318    visit_families(results, &mut pass);
319    results.security_findings.retain(|finding| {
320        let keep = ids.contains(&finding.finding_id);
321        if keep {
322            pass.matched.insert(finding.finding_id.clone());
323        }
324        keep
325    });
326    pass.matched
327}
328
329/// The ids in `ids` that a dead-code finding in `results` carries.
330///
331/// The pass only reads the findings. It takes `results` mutably because it
332/// shares the family visitor with the passes that write.
333#[expect(
334    clippy::implicit_hasher,
335    reason = "fallow standardizes on FxHashSet across the workspace"
336)]
337pub fn present_dead_code_finding_ids(
338    results: &mut AnalysisResults,
339    ids: &FxHashSet<String>,
340) -> FxHashSet<String> {
341    let mut pass = RetainPass {
342        ids,
343        matched: FxHashSet::default(),
344        keep_all: true,
345    };
346    visit_families(results, &mut pass);
347    pass.matched.extend(
348        results
349            .security_findings
350            .iter()
351            .filter(|finding| ids.contains(&finding.finding_id))
352            .map(|finding| finding.finding_id.clone()),
353    );
354    pass.matched
355}
356
357/// Whether `id` has the syntax of a current dead-code finding id:
358/// `dc1:<rule>:<16 lowercase hex digits>`, with an optional `~<k>` suffix
359/// where `k` is a positive decimal number.
360#[must_use]
361pub fn is_dead_code_finding_id(id: &str) -> bool {
362    let Some(rest) = id
363        .strip_prefix(DEAD_CODE_ID_SCHEME)
364        .and_then(|rest| rest.strip_prefix(':'))
365    else {
366        return false;
367    };
368    let Some((rule, tail)) = rest.split_once(':') else {
369        return false;
370    };
371    let rule_ok = !rule.is_empty()
372        && rule
373            .bytes()
374            .all(|byte| byte.is_ascii_lowercase() || byte == b'-');
375    let (hash, suffix) = match tail.split_once('~') {
376        Some((hash, suffix)) => (hash, Some(suffix)),
377        None => (tail, None),
378    };
379    let hash_ok = hash.len() == HASH_HEX_DIGITS
380        && hash
381            .bytes()
382            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
383    let suffix_ok = suffix.is_none_or(|suffix| {
384        !suffix.is_empty()
385            && !suffix.starts_with('0')
386            && suffix.bytes().all(|byte| byte.is_ascii_digit())
387    });
388    rule_ok && hash_ok && suffix_ok
389}
390
391/// The number of hex digits in the hash part of a finding id.
392const HASH_HEX_DIGITS: usize = 16;
393
394/// One pass over every dead-code finding family.
395trait FamilyVisitor {
396    fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>);
397}
398
399/// Writes ids, see [`StampMode`].
400struct StampPass<'a> {
401    paths: IdentityPaths<'a>,
402    mode: StampMode,
403}
404
405impl FamilyVisitor for StampPass<'_> {
406    fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>) {
407        apply(findings, &self.paths, self.mode);
408    }
409}
410
411/// Records which of `ids` the findings carry. Removes the other findings
412/// unless `keep_all` is set.
413struct RetainPass<'a> {
414    ids: &'a FxHashSet<String>,
415    matched: FxHashSet<String>,
416    keep_all: bool,
417}
418
419impl FamilyVisitor for RetainPass<'_> {
420    fn visit<T: IdentifiedFinding>(&mut self, findings: &mut Vec<T>) {
421        let keep_all = self.keep_all;
422        findings.retain(|finding| {
423            let matched = finding
424                .finding_id()
425                .filter(|id| self.ids.contains(*id))
426                .map(|id| self.matched.insert(id.to_owned()))
427                .is_some();
428            matched || keep_all
429        });
430    }
431}
432
433#[expect(
434    clippy::too_many_lines,
435    reason = "one exhaustive list of finding families; splitting it would lose the compile-time guard"
436)]
437fn visit_families<V: FamilyVisitor>(results: &mut AnalysisResults, visitor: &mut V) {
438    // No `..` rest pattern: a new field fails to compile here until it is
439    // classified as a finding family or as metadata.
440    let AnalysisResults {
441        unused_files,
442        unused_exports,
443        unused_types,
444        private_type_leaks,
445        deprecated_exports_in_use,
446        unused_dependencies,
447        unused_dev_dependencies,
448        unused_optional_dependencies,
449        unused_enum_members,
450        unused_class_members,
451        unused_store_members,
452        unresolved_imports,
453        unlisted_dependencies,
454        duplicate_exports,
455        type_only_dependencies,
456        test_only_dependencies,
457        dev_dependencies_in_production,
458        circular_dependencies,
459        package_cycles,
460        re_export_cycles,
461        boundary_violations,
462        boundary_coverage_violations,
463        boundary_call_violations,
464        policy_violations,
465        stale_suppressions,
466        unused_catalog_entries,
467        empty_catalog_groups,
468        unresolved_catalog_references,
469        unused_dependency_overrides,
470        misconfigured_dependency_overrides,
471        invalid_client_exports,
472        mixed_client_server_barrels,
473        misplaced_directives,
474        unprovided_injects,
475        unrendered_components,
476        route_collisions,
477        dynamic_segment_name_conflicts,
478        unused_component_props,
479        unused_component_emits,
480        unused_component_inputs,
481        unused_component_outputs,
482        unused_svelte_events,
483        unused_server_actions,
484        unused_load_data_keys,
485        prop_drilling_chains,
486        thin_wrappers,
487        duplicate_prop_shapes,
488        // Security findings carry their own `finding_id` from
489        // `fallow_security::identity`. The other fields are not findings.
490        security_findings: _,
491        security_unresolved_edge_files: _,
492        security_unresolved_callee_sites: _,
493        security_unresolved_callee_diagnostics: _,
494        unused_load_data_keys_global_abstain: _,
495        suppression_count: _,
496        unused_component_props_exempted: _,
497        active_suppressions: _,
498        feature_flags: _,
499        export_usages: _,
500        entry_point_summary: _,
501        render_fan_in: _,
502        react_component_intel: _,
503        semantic_framework_contracts: _,
504    } = results;
505
506    visitor.visit(unused_files);
507    visitor.visit(unused_exports);
508    visitor.visit(unused_types);
509    visitor.visit(private_type_leaks);
510    visitor.visit(deprecated_exports_in_use);
511    visitor.visit(unused_dependencies);
512    visitor.visit(unused_dev_dependencies);
513    visitor.visit(unused_optional_dependencies);
514    visitor.visit(unused_enum_members);
515    visitor.visit(unused_class_members);
516    visitor.visit(unused_store_members);
517    visitor.visit(unresolved_imports);
518    visitor.visit(unlisted_dependencies);
519    visitor.visit(duplicate_exports);
520    visitor.visit(type_only_dependencies);
521    visitor.visit(test_only_dependencies);
522    visitor.visit(dev_dependencies_in_production);
523    visitor.visit(circular_dependencies);
524    visitor.visit(package_cycles);
525    visitor.visit(re_export_cycles);
526    visitor.visit(boundary_violations);
527    visitor.visit(boundary_coverage_violations);
528    visitor.visit(boundary_call_violations);
529    visitor.visit(policy_violations);
530    visitor.visit(stale_suppressions);
531    visitor.visit(unused_catalog_entries);
532    visitor.visit(empty_catalog_groups);
533    visitor.visit(unresolved_catalog_references);
534    visitor.visit(unused_dependency_overrides);
535    visitor.visit(misconfigured_dependency_overrides);
536    visitor.visit(invalid_client_exports);
537    visitor.visit(mixed_client_server_barrels);
538    visitor.visit(misplaced_directives);
539    visitor.visit(unprovided_injects);
540    visitor.visit(unrendered_components);
541    visitor.visit(route_collisions);
542    visitor.visit(dynamic_segment_name_conflicts);
543    visitor.visit(unused_component_props);
544    visitor.visit(unused_component_emits);
545    visitor.visit(unused_component_inputs);
546    visitor.visit(unused_component_outputs);
547    visitor.visit(unused_svelte_events);
548    visitor.visit(unused_server_actions);
549    visitor.visit(unused_load_data_keys);
550    visitor.visit(prop_drilling_chains);
551    visitor.visit(thin_wrappers);
552    visitor.visit(duplicate_prop_shapes);
553}
554
555fn apply<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>, mode: StampMode) {
556    match mode {
557        StampMode::All => stamp(findings, paths),
558        StampMode::Missing => stamp_missing(findings, paths),
559    }
560}
561
562fn base_id<T: IdentifiedFinding>(finding: &T, paths: &IdentityPaths<'_>) -> String {
563    let parts = finding.identity_parts(paths);
564    let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
565    dead_code_finding_id(finding.rule_token(), &parts)
566}
567
568/// The order of findings that share a base id: position first, then the
569/// serialized finding, so the order does not depend on the input order.
570fn tiebreak_key<T: IdentifiedFinding>(finding: &T) -> ((u32, u32, u32), String) {
571    (
572        finding.tiebreak_position(),
573        serde_json::to_string(finding).unwrap_or_default(),
574    )
575}
576
577/// Stamp one finding family. The result does not depend on the input order.
578fn stamp<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
579    let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
580    for (index, finding) in findings.iter_mut().enumerate() {
581        finding.set_finding_id(None);
582        groups
583            .entry(base_id(finding, paths))
584            .or_default()
585            .push(index);
586    }
587    for (base, mut members) in groups {
588        if members.len() > 1 {
589            members.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
590        }
591        for (position, index) in members.into_iter().enumerate() {
592            let id = if position == 0 {
593                base.clone()
594            } else {
595                format!("{base}~{position}")
596            };
597            findings[index].set_finding_id(Some(id));
598        }
599    }
600}
601
602/// Give an id to each finding of one family that has none.
603fn stamp_missing<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
604    let mut missing: Vec<usize> = (0..findings.len())
605        .filter(|&index| findings[index].finding_id().is_none())
606        .collect();
607    if missing.is_empty() {
608        return;
609    }
610    let mut taken: FxHashSet<String> = findings
611        .iter()
612        .filter_map(|finding| finding.finding_id().map(str::to_owned))
613        .collect();
614    missing.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
615    for index in missing {
616        let base = base_id(&findings[index], paths);
617        let mut id = base.clone();
618        let mut suffix = 0_usize;
619        while taken.contains(&id) {
620            suffix += 1;
621            id = format!("{base}~{suffix}");
622        }
623        taken.insert(id.clone());
624        findings[index].set_finding_id(Some(id));
625    }
626}
627
628/// Implement [`IdentifiedFinding`] for a type with a `finding_id` field.
629macro_rules! identified {
630    (
631        $ty:ty,
632        token: |$t:ident| $token:expr,
633        parts: |$f:ident, $p:ident| $parts:expr,
634        position: |$g:ident| $position:expr $(,)?
635    ) => {
636        impl IdentifiedFinding for $ty {
637            fn rule_token(&self) -> &'static str {
638                let $t = self;
639                $token
640            }
641
642            fn identity_parts(&self, $p: &IdentityPaths<'_>) -> Vec<String> {
643                let $f = self;
644                $parts
645            }
646
647            fn tiebreak_position(&self) -> (u32, u32, u32) {
648                let $g = self;
649                $position
650            }
651
652            fn finding_id(&self) -> Option<&str> {
653                self.finding_id.as_deref()
654            }
655
656            fn set_finding_id(&mut self, id: Option<String>) {
657                self.finding_id = id;
658            }
659        }
660    };
661}
662
663identified!(
664    UnusedFileFinding,
665    token: |_t| "unused-file",
666    parts: |f, p| vec![p.key(&f.file.path)],
667    position: |_g| (0, 0, 0),
668);
669
670identified!(
671    UnusedExportFinding,
672    token: |_t| "unused-export",
673    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
674    position: |g| (g.export.line, g.export.col, g.export.span_start),
675);
676
677identified!(
678    UnusedTypeFinding,
679    token: |_t| "unused-type",
680    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
681    position: |g| (g.export.line, g.export.col, g.export.span_start),
682);
683
684identified!(
685    PrivateTypeLeakFinding,
686    token: |_t| "private-type-leak",
687    parts: |f, p| vec![
688        p.key(&f.leak.path),
689        f.leak.export_name.clone(),
690        f.leak.type_name.clone(),
691    ],
692    position: |g| (g.leak.line, g.leak.col, g.leak.span_start),
693);
694
695identified!(
696    DeprecatedExportInUseFinding,
697    token: |_t| "deprecated-export-in-use",
698    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
699    position: |g| (g.export.line, g.export.col, g.export.span_start),
700);
701
702identified!(
703    UnusedDependencyFinding,
704    token: |_t| "unused-dependency",
705    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
706    position: |g| (g.dep.line, 0, 0),
707);
708
709identified!(
710    UnusedDevDependencyFinding,
711    token: |_t| "unused-dev-dependency",
712    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
713    position: |g| (g.dep.line, 0, 0),
714);
715
716identified!(
717    UnusedOptionalDependencyFinding,
718    token: |_t| "unused-optional-dependency",
719    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
720    position: |g| (g.dep.line, 0, 0),
721);
722
723identified!(
724    TypeOnlyDependencyFinding,
725    token: |_t| "type-only-dependency",
726    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
727    position: |g| (g.dep.line, 0, 0),
728);
729
730identified!(
731    TestOnlyDependencyFinding,
732    token: |_t| "test-only-dependency",
733    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
734    position: |g| (g.dep.line, 0, 0),
735);
736
737identified!(
738    DevDependencyInProductionFinding,
739    token: |_t| "dev-dependency-in-production",
740    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
741    position: |g| (g.dep.line, 0, 0),
742);
743
744identified!(
745    UnlistedDependencyFinding,
746    token: |_t| "unlisted-dependency",
747    parts: |f, _p| vec![f.dep.package_name.clone()],
748    position: |_g| (0, 0, 0),
749);
750
751identified!(
752    UnusedEnumMemberFinding,
753    token: |_t| "unused-enum-member",
754    parts: |f, p| member_parts(&f.member, p),
755    position: |g| (g.member.line, g.member.col, 0),
756);
757
758identified!(
759    UnusedClassMemberFinding,
760    token: |_t| "unused-class-member",
761    parts: |f, p| member_parts(&f.member, p),
762    position: |g| (g.member.line, g.member.col, 0),
763);
764
765identified!(
766    UnusedStoreMemberFinding,
767    token: |_t| "unused-store-member",
768    parts: |f, p| member_parts(&f.member, p),
769    position: |g| (g.member.line, g.member.col, 0),
770);
771
772fn member_parts(member: &crate::results::UnusedMember, paths: &IdentityPaths<'_>) -> Vec<String> {
773    vec![
774        paths.key(&member.path),
775        member.parent_name.clone(),
776        member.member_name.clone(),
777    ]
778}
779
780identified!(
781    UnresolvedImportFinding,
782    token: |_t| "unresolved-import",
783    parts: |f, p| vec![p.key(&f.import.path), f.import.specifier.clone()],
784    position: |g| (g.import.line, g.import.col, 0),
785);
786
787identified!(
788    DuplicateExportFinding,
789    token: |_t| "duplicate-export",
790    parts: |f, p| vec![
791        f.export.export_name.clone(),
792        p.set(f.export.locations.iter().map(|location| location.path.as_path())),
793    ],
794    position: |g| g
795        .export
796        .locations
797        .first()
798        .map_or((0, 0, 0), |location| (location.line, location.col, 0)),
799);
800
801identified!(
802    CircularDependencyFinding,
803    token: |_t| "circular-dependency",
804    parts: |f, p| vec![p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path))],
805    position: |g| (g.cycle.line, g.cycle.col, 0),
806);
807
808identified!(
809    PackageCycleFinding,
810    token: |_t| "package-cycle",
811    parts: |f, p| vec![p.set(f.cycle.package_roots.iter().map(std::path::PathBuf::as_path))],
812    position: |_g| (0, 0, 0),
813);
814
815identified!(
816    ReExportCycleFinding,
817    token: |_t| "re-export-cycle",
818    parts: |f, p| vec![
819        re_export_cycle_kind(f.cycle.kind).to_owned(),
820        p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path)),
821    ],
822    position: |_g| (0, 0, 0),
823);
824
825/// The wire spelling of the cycle kind, so the part matches the JSON value.
826const fn re_export_cycle_kind(kind: ReExportCycleKind) -> &'static str {
827    match kind {
828        ReExportCycleKind::MultiNode => "multi-node",
829        ReExportCycleKind::SelfLoop => "self-loop",
830    }
831}
832
833identified!(
834    BoundaryViolationFinding,
835    token: |_t| "boundary-violation",
836    parts: |f, p| vec![p.key(&f.violation.from_path), p.key(&f.violation.to_path)],
837    position: |g| (g.violation.line, g.violation.col, 0),
838);
839
840identified!(
841    BoundaryCoverageViolationFinding,
842    token: |_t| "boundary-coverage",
843    parts: |f, p| vec![p.key(&f.violation.path)],
844    position: |g| (g.violation.line, g.violation.col, 0),
845);
846
847identified!(
848    BoundaryCallViolationFinding,
849    token: |_t| "boundary-call-violation",
850    parts: |f, p| vec![p.key(&f.violation.path), f.violation.callee.clone()],
851    position: |g| (g.violation.line, g.violation.col, 0),
852);
853
854identified!(
855    PolicyViolationFinding,
856    token: |_t| "policy-violation",
857    parts: |f, p| vec![
858        p.key(&f.violation.path),
859        f.violation.pack.clone(),
860        f.violation.rule_id.clone(),
861        f.violation.matched.clone(),
862    ],
863    position: |g| (g.violation.line, g.violation.col, 0),
864);
865
866identified!(
867    StaleSuppression,
868    token: |t| if t.missing_reason {
869        "missing-suppression-reason"
870    } else {
871        "stale-suppression"
872    },
873    parts: |f, p| suppression_parts(f, p),
874    position: |g| (g.line, g.col, 0),
875);
876
877/// Path, origin kind, issue kind (or `*`), and scope or export name. The
878/// reason text is not a part: adding a reason must not change the id.
879fn suppression_parts(suppression: &StaleSuppression, paths: &IdentityPaths<'_>) -> Vec<String> {
880    let path = paths.key(&suppression.path);
881    match &suppression.origin {
882        SuppressionOrigin::Comment {
883            issue_kind,
884            is_file_level,
885            ..
886        } => vec![
887            path,
888            "comment".to_owned(),
889            issue_kind.clone().unwrap_or_else(|| ANY_KIND.to_owned()),
890            if *is_file_level { "file" } else { "line" }.to_owned(),
891        ],
892        SuppressionOrigin::JsdocTag { export_name, .. } => vec![
893            path,
894            "jsdoc_tag".to_owned(),
895            ANY_KIND.to_owned(),
896            export_name.clone(),
897        ],
898    }
899}
900
901identified!(
902    UnusedCatalogEntryFinding,
903    token: |_t| "unused-catalog-entry",
904    parts: |f, p| vec![
905        p.key(&f.entry.path),
906        f.entry.catalog_name.clone(),
907        f.entry.entry_name.clone(),
908    ],
909    position: |g| (g.entry.line, 0, 0),
910);
911
912identified!(
913    EmptyCatalogGroupFinding,
914    token: |_t| "empty-catalog-group",
915    parts: |f, p| vec![p.key(&f.group.path), f.group.catalog_name.clone()],
916    position: |g| (g.group.line, 0, 0),
917);
918
919identified!(
920    UnresolvedCatalogReferenceFinding,
921    token: |_t| "unresolved-catalog-reference",
922    parts: |f, p| vec![
923        p.key(&f.reference.path),
924        f.reference.catalog_name.clone(),
925        f.reference.entry_name.clone(),
926    ],
927    position: |g| (g.reference.line, 0, 0),
928);
929
930identified!(
931    UnusedDependencyOverrideFinding,
932    token: |_t| "unused-dependency-override",
933    parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
934    position: |g| (g.entry.line, 0, 0),
935);
936
937identified!(
938    MisconfiguredDependencyOverrideFinding,
939    token: |_t| "misconfigured-dependency-override",
940    parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
941    position: |g| (g.entry.line, 0, 0),
942);
943
944/// The wire spelling of the override source, so the part matches the JSON
945/// value.
946const fn override_source(source: DependencyOverrideSource) -> &'static str {
947    match source {
948        DependencyOverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
949        DependencyOverrideSource::PnpmPackageJson => "package.json",
950    }
951}
952
953identified!(
954    InvalidClientExportFinding,
955    token: |_t| "invalid-client-export",
956    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
957    position: |g| (g.export.line, g.export.col, 0),
958);
959
960identified!(
961    MixedClientServerBarrelFinding,
962    token: |_t| "mixed-client-server-barrel",
963    parts: |f, p| vec![p.key(&f.barrel.path)],
964    position: |g| (g.barrel.line, g.barrel.col, 0),
965);
966
967identified!(
968    MisplacedDirectiveFinding,
969    token: |_t| "misplaced-directive",
970    parts: |f, p| vec![
971        p.key(&f.directive_site.path),
972        f.directive_site.directive.clone(),
973    ],
974    position: |g| (g.directive_site.line, g.directive_site.col, 0),
975);
976
977identified!(
978    UnprovidedInjectFinding,
979    token: |_t| "unprovided-inject",
980    parts: |f, p| vec![p.key(&f.inject.path), f.inject.key_name.clone()],
981    position: |g| (g.inject.line, g.inject.col, 0),
982);
983
984identified!(
985    UnrenderedComponentFinding,
986    token: |_t| "unrendered-component",
987    parts: |f, p| vec![p.key(&f.component.path), f.component.component_name.clone()],
988    position: |g| (g.component.line, g.component.col, 0),
989);
990
991identified!(
992    RouteCollisionFinding,
993    token: |_t| "route-collision",
994    parts: |f, p| vec![p.key(&f.collision.path), f.collision.url.clone()],
995    position: |g| (g.collision.line, g.collision.col, 0),
996);
997
998identified!(
999    DynamicSegmentNameConflictFinding,
1000    token: |_t| "dynamic-segment-name-conflict",
1001    parts: |f, p| vec![p.key(&f.conflict.path), f.conflict.position.clone()],
1002    position: |g| (g.conflict.line, g.conflict.col, 0),
1003);
1004
1005identified!(
1006    UnusedComponentPropFinding,
1007    token: |_t| "unused-component-prop",
1008    parts: |f, p| vec![
1009        p.key(&f.prop.path),
1010        f.prop.component_name.clone(),
1011        f.prop.prop_name.clone(),
1012    ],
1013    position: |g| (g.prop.line, g.prop.col, 0),
1014);
1015
1016identified!(
1017    UnusedComponentEmitFinding,
1018    token: |_t| "unused-component-emit",
1019    parts: |f, p| vec![
1020        p.key(&f.emit.path),
1021        f.emit.component_name.clone(),
1022        f.emit.emit_name.clone(),
1023    ],
1024    position: |g| (g.emit.line, g.emit.col, 0),
1025);
1026
1027identified!(
1028    UnusedComponentInputFinding,
1029    token: |_t| "unused-component-input",
1030    parts: |f, p| vec![
1031        p.key(&f.input.path),
1032        f.input.component_name.clone(),
1033        f.input.input_name.clone(),
1034    ],
1035    position: |g| (g.input.line, g.input.col, 0),
1036);
1037
1038identified!(
1039    UnusedComponentOutputFinding,
1040    token: |_t| "unused-component-output",
1041    parts: |f, p| vec![
1042        p.key(&f.output.path),
1043        f.output.component_name.clone(),
1044        f.output.output_name.clone(),
1045    ],
1046    position: |g| (g.output.line, g.output.col, 0),
1047);
1048
1049identified!(
1050    UnusedSvelteEventFinding,
1051    token: |_t| "unused-svelte-event",
1052    parts: |f, p| vec![
1053        p.key(&f.event.path),
1054        f.event.component_name.clone(),
1055        f.event.event_name.clone(),
1056    ],
1057    position: |g| (g.event.line, g.event.col, 0),
1058);
1059
1060identified!(
1061    UnusedServerActionFinding,
1062    token: |_t| "unused-server-action",
1063    parts: |f, p| vec![p.key(&f.action.path), f.action.action_name.clone()],
1064    position: |g| (g.action.line, g.action.col, 0),
1065);
1066
1067identified!(
1068    UnusedLoadDataKeyFinding,
1069    token: |_t| "unused-load-data-key",
1070    parts: |f, p| vec![p.key(&f.key.path), f.key.key_name.clone()],
1071    position: |g| (g.key.line, g.key.col, 0),
1072);
1073
1074identified!(
1075    PropDrillingChainFinding,
1076    token: |_t| "prop-drilling",
1077    parts: |f, p| {
1078        let origin = f.chain.hops.first();
1079        vec![
1080            origin.map(|hop| p.key(&hop.file)).unwrap_or_default(),
1081            origin.map(|hop| hop.component.clone()).unwrap_or_default(),
1082            f.chain.prop.clone(),
1083        ]
1084    },
1085    position: |g| (g.chain.hops.first().map_or(0, |hop| hop.line), 0, 0),
1086);
1087
1088identified!(
1089    ThinWrapperFinding,
1090    token: |_t| "thin-wrapper",
1091    parts: |f, p| vec![p.key(&f.wrapper.file), f.wrapper.component.clone()],
1092    position: |g| (g.wrapper.line, 0, 0),
1093);
1094
1095identified!(
1096    DuplicatePropShapeFinding,
1097    token: |_t| "duplicate-prop-shape",
1098    parts: |f, p| vec![p.key(&f.shape.file), f.shape.component.clone()],
1099    position: |g| (g.shape.line, 0, 0),
1100);
1101
1102#[cfg(test)]
1103mod tests {
1104    use std::path::PathBuf;
1105
1106    use super::*;
1107    use crate::results::{UnusedExport, UnusedFile, UnusedMember};
1108
1109    // Every expected value below comes from an independent FNV-1a 64 script,
1110    // not from this module.
1111
1112    #[test]
1113    fn fnv1a64_parts_golden_values() {
1114        assert_eq!(
1115            fnv1a64_parts(&["src/index.ts", "FEATURE_X", "3"]),
1116            "2278c9d9bd9d2dd2"
1117        );
1118        assert_eq!(
1119            fnv1a64_parts(&["fallow/unused-file", "src/orphan.ts"]),
1120            "c03b925ddb7d871a"
1121        );
1122        assert_eq!(fnv1a64_parts(&[]), "cbf29ce484222325");
1123    }
1124
1125    #[test]
1126    fn fnv1a64_hex_golden_value() {
1127        assert_eq!(
1128            fnv1a64_hex(b"security/client-server-leak:src/a.ts:3:7"),
1129            "89b6dece9d8b96d2"
1130        );
1131    }
1132
1133    #[test]
1134    fn finding_id_syntax_accepts_base_and_tiebreak_ids() {
1135        assert!(is_dead_code_finding_id(
1136            "dc1:unused-export:81a349a3b9ea3b15"
1137        ));
1138        assert!(is_dead_code_finding_id(
1139            "dc1:unused-class-member:0123456789abcdef~1"
1140        ));
1141        assert!(is_dead_code_finding_id(
1142            "dc1:unused-file:0123456789abcdef~12"
1143        ));
1144    }
1145
1146    #[test]
1147    fn finding_id_syntax_refuses_other_shapes() {
1148        for bad in [
1149            "",
1150            "dc1",
1151            "dc1:unused-export",
1152            "dc1::0123456789abcdef",
1153            "dc2:unused-export:0123456789abcdef",
1154            "dc1:unused-export:0123456789ABCDEF",
1155            "dc1:unused-export:0123456789abcde",
1156            "dc1:unused-export:0123456789abcdef0",
1157            "dc1:unused-export:0123456789abcdef~",
1158            "dc1:unused-export:0123456789abcdef~0",
1159            "dc1:unused-export:0123456789abcdef~01",
1160            "dc1:unused-export:0123456789abcdef~x",
1161            "dc1:Unused-Export:0123456789abcdef",
1162            "dc1:unused-export:0123456789abcdef:extra",
1163        ] {
1164            assert!(!is_dead_code_finding_id(bad), "{bad:?} was accepted");
1165        }
1166    }
1167
1168    #[test]
1169    fn retain_by_id_keeps_only_the_requested_findings() {
1170        let root = PathBuf::from("/repo");
1171        let mut results = AnalysisResults {
1172            unused_files: vec![
1173                UnusedFileFinding::with_actions(UnusedFile {
1174                    path: root.join("src/a.ts"),
1175                }),
1176                UnusedFileFinding::with_actions(UnusedFile {
1177                    path: root.join("src/b.ts"),
1178                }),
1179            ],
1180            ..AnalysisResults::default()
1181        };
1182        stamp_dead_code_finding_ids(&mut results, &root);
1183        let kept = results.unused_files[1]
1184            .finding_id
1185            .clone()
1186            .expect("stamped id");
1187        let ids: FxHashSet<String> = [kept.clone(), "dc1:unused-file:0000000000000000".to_owned()]
1188            .into_iter()
1189            .collect();
1190
1191        let present = present_dead_code_finding_ids(&mut results, &ids);
1192        assert_eq!(results.unused_files.len(), 2, "present only reads");
1193        let matched = retain_dead_code_findings_by_id(&mut results, &ids);
1194
1195        assert_eq!(present, matched);
1196        assert_eq!(matched, std::iter::once(kept.clone()).collect());
1197        assert_eq!(results.unused_files.len(), 1);
1198        assert_eq!(
1199            results.unused_files[0].finding_id.as_deref(),
1200            Some(kept.as_str())
1201        );
1202    }
1203
1204    #[test]
1205    fn dead_code_finding_id_golden_values() {
1206        assert_eq!(
1207            dead_code_finding_id("unused-export", &["src/utils.ts", "helper"]),
1208            "dc1:unused-export:81a349a3b9ea3b15"
1209        );
1210        assert_eq!(
1211            dead_code_finding_id("unused-file", &["src/orphan.ts"]),
1212            "dc1:unused-file:9fd2d414a2a9e611"
1213        );
1214        assert_eq!(
1215            dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"]),
1216            "dc1:unused-class-member:675fa79a4c2f244f"
1217        );
1218        assert_eq!(
1219            dead_code_finding_id("unused-dependency", &["package.json", "lodash"]),
1220            "dc1:unused-dependency:e2f217ff5a209568"
1221        );
1222        assert_eq!(
1223            dead_code_finding_id("duplicate-export", &["Button", "src/a.ts|src/b.ts"]),
1224            "dc1:duplicate-export:17c140e16d40660a"
1225        );
1226    }
1227
1228    fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
1229        UnusedExportFinding::with_actions(UnusedExport {
1230            path: root.join("src/utils.ts"),
1231            export_name: name.to_owned(),
1232            is_type_only: false,
1233            line,
1234            col: 7,
1235            span_start: line * 10,
1236            is_re_export: false,
1237            deprecated: false,
1238            deprecated_reason: None,
1239        })
1240    }
1241
1242    fn member(root: &Path, line: u32) -> UnusedClassMemberFinding {
1243        UnusedClassMemberFinding::with_actions(UnusedMember {
1244            path: root.join("src/service.ts"),
1245            parent_name: "Service".to_owned(),
1246            member_name: "run".to_owned(),
1247            kind: crate::extract::MemberKind::ClassMethod,
1248            line,
1249            col: 2,
1250        })
1251    }
1252
1253    fn ids<T: IdentifiedFinding>(findings: &[T]) -> Vec<Option<String>> {
1254        findings
1255            .iter()
1256            .map(|finding| finding.finding_id().map(str::to_owned))
1257            .collect()
1258    }
1259
1260    #[test]
1261    fn stamping_uses_root_relative_paths_and_ignores_lines() {
1262        let root = PathBuf::from("/repo");
1263        let mut results = AnalysisResults {
1264            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1265                path: root.join("src/orphan.ts"),
1266            })],
1267            unused_exports: vec![export(&root, "helper", 40)],
1268            ..AnalysisResults::default()
1269        };
1270
1271        stamp_dead_code_finding_ids(&mut results, &root);
1272
1273        assert_eq!(
1274            ids(&results.unused_files),
1275            vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1276        );
1277        assert_eq!(
1278            ids(&results.unused_exports),
1279            vec![Some("dc1:unused-export:81a349a3b9ea3b15".to_owned())]
1280        );
1281    }
1282
1283    #[test]
1284    fn duplicate_subjects_get_a_tiebreak_suffix_in_line_order() {
1285        let root = PathBuf::from("/repo");
1286        let mut results = AnalysisResults {
1287            unused_class_members: vec![member(&root, 30), member(&root, 10), member(&root, 20)],
1288            ..AnalysisResults::default()
1289        };
1290
1291        stamp_dead_code_finding_ids(&mut results, &root);
1292
1293        let base = "dc1:unused-class-member:675fa79a4c2f244f";
1294        assert_eq!(
1295            ids(&results.unused_class_members),
1296            vec![
1297                Some(format!("{base}~2")),
1298                Some(base.to_owned()),
1299                Some(format!("{base}~1")),
1300            ]
1301        );
1302    }
1303
1304    #[test]
1305    fn stamping_does_not_depend_on_input_order() {
1306        let root = PathBuf::from("/repo");
1307        let forward = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1308        let mut reversed = forward.clone();
1309        reversed.reverse();
1310        let mut a = AnalysisResults {
1311            unused_class_members: forward,
1312            ..AnalysisResults::default()
1313        };
1314        let mut b = AnalysisResults {
1315            unused_class_members: reversed,
1316            ..AnalysisResults::default()
1317        };
1318
1319        stamp_dead_code_finding_ids(&mut a, &root);
1320        stamp_dead_code_finding_ids(&mut b, &root);
1321
1322        let mut a_pairs: Vec<(u32, Option<String>)> = a
1323            .unused_class_members
1324            .iter()
1325            .map(|finding| (finding.member.line, finding.finding_id.clone()))
1326            .collect();
1327        let mut b_pairs: Vec<(u32, Option<String>)> = b
1328            .unused_class_members
1329            .iter()
1330            .map(|finding| (finding.member.line, finding.finding_id.clone()))
1331            .collect();
1332        a_pairs.sort();
1333        b_pairs.sort();
1334        assert_eq!(a_pairs, b_pairs);
1335    }
1336
1337    #[test]
1338    fn stamping_twice_gives_the_same_ids() {
1339        let root = PathBuf::from("/repo");
1340        let mut results = AnalysisResults {
1341            unused_class_members: vec![member(&root, 10), member(&root, 20)],
1342            unused_exports: vec![export(&root, "helper", 3)],
1343            ..AnalysisResults::default()
1344        };
1345
1346        stamp_dead_code_finding_ids(&mut results, &root);
1347        let first = (
1348            ids(&results.unused_class_members),
1349            ids(&results.unused_exports),
1350        );
1351        stamp_dead_code_finding_ids(&mut results, &root);
1352
1353        assert_eq!(
1354            first,
1355            (
1356                ids(&results.unused_class_members),
1357                ids(&results.unused_exports),
1358            )
1359        );
1360    }
1361
1362    #[test]
1363    fn stamping_missing_ids_keeps_existing_ids_and_takes_a_free_suffix() {
1364        let root = PathBuf::from("/repo");
1365        let mut results = AnalysisResults {
1366            unused_class_members: vec![member(&root, 10), member(&root, 20)],
1367            ..AnalysisResults::default()
1368        };
1369        stamp_dead_code_finding_ids(&mut results, &root);
1370        // A filter removed the base finding; a later stage adds a new one.
1371        results.unused_class_members.remove(0);
1372        results.unused_class_members.push(member(&root, 5));
1373
1374        stamp_missing_dead_code_finding_ids(&mut results, &root);
1375
1376        let base = "dc1:unused-class-member:675fa79a4c2f244f";
1377        assert_eq!(
1378            ids(&results.unused_class_members),
1379            vec![Some(format!("{base}~1")), Some(base.to_owned())]
1380        );
1381    }
1382
1383    fn duplicate_export(root: &Path, files: &[&str]) -> DuplicateExportFinding {
1384        DuplicateExportFinding::with_actions(crate::results::DuplicateExport {
1385            export_name: "Button".to_owned(),
1386            locations: files
1387                .iter()
1388                .map(|file| crate::results::DuplicateLocation {
1389                    path: root.join(file),
1390                    line: 1,
1391                    col: 0,
1392                })
1393                .collect(),
1394        })
1395    }
1396
1397    #[test]
1398    fn a_path_set_without_special_characters_keeps_its_golden_id() {
1399        let root = PathBuf::from("/repo");
1400        let mut results = AnalysisResults {
1401            duplicate_exports: vec![duplicate_export(&root, &["src/b.ts", "src/a.ts"])],
1402            ..AnalysisResults::default()
1403        };
1404
1405        stamp_dead_code_finding_ids(&mut results, &root);
1406
1407        assert_eq!(
1408            ids(&results.duplicate_exports),
1409            vec![Some("dc1:duplicate-export:17c140e16d40660a".to_owned())]
1410        );
1411    }
1412
1413    #[test]
1414    fn a_pipe_in_a_file_name_does_not_collide_with_two_files() {
1415        let root = PathBuf::from("/repo");
1416        let paths = IdentityPaths::new(&root);
1417        let one = root.join("src/a.ts|src/b.ts");
1418        let first = root.join("src/a.ts");
1419        let second = root.join("src/b.ts");
1420
1421        assert_eq!(paths.set([one.as_path()]), "src/a.ts%7Csrc/b.ts");
1422        assert_eq!(
1423            paths.set([first.as_path(), second.as_path()]),
1424            "src/a.ts|src/b.ts"
1425        );
1426        assert_eq!(paths.set([root.join("100%.ts").as_path()]), "100%25.ts");
1427
1428        let mut results = AnalysisResults {
1429            duplicate_exports: vec![
1430                duplicate_export(&root, &["src/a.ts|src/b.ts"]),
1431                duplicate_export(&root, &["src/a.ts", "src/b.ts"]),
1432            ],
1433            ..AnalysisResults::default()
1434        };
1435        stamp_dead_code_finding_ids(&mut results, &root);
1436
1437        let stamped = ids(&results.duplicate_exports);
1438        assert_ne!(stamped[0], stamped[1]);
1439        assert!(
1440            stamped.iter().flatten().all(|id| !id.contains('~')),
1441            "the two findings must not share a base id: {stamped:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn a_package_cycle_gets_a_golden_id_from_its_sorted_package_roots() {
1447        let root = PathBuf::from("/repo");
1448        let cycle = |roots: &[&str]| {
1449            PackageCycleFinding::with_actions(crate::results::PackageCycle {
1450                packages: vec!["@x/a".to_owned(), "@x/b".to_owned()],
1451                package_roots: roots.iter().map(|dir| root.join(dir)).collect(),
1452                length: 2,
1453                edges: Vec::new(),
1454                group_truncated: false,
1455            })
1456        };
1457        let mut results = AnalysisResults {
1458            package_cycles: vec![cycle(&["packages/b", "packages/a"])],
1459            ..AnalysisResults::default()
1460        };
1461
1462        stamp_dead_code_finding_ids(&mut results, &root);
1463
1464        assert_eq!(
1465            ids(&results.package_cycles),
1466            vec![Some("dc1:package-cycle:fed127e4525389ac".to_owned())]
1467        );
1468    }
1469
1470    #[test]
1471    fn windows_separators_give_the_same_id() {
1472        let mut windows = AnalysisResults {
1473            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1474                path: PathBuf::from("src\\orphan.ts"),
1475            })],
1476            ..AnalysisResults::default()
1477        };
1478
1479        stamp_dead_code_finding_ids(&mut windows, Path::new(""));
1480
1481        assert_eq!(
1482            ids(&windows.unused_files),
1483            vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1484        );
1485    }
1486
1487    #[test]
1488    fn canonical_keys_are_readable_and_escape_the_separator() {
1489        assert_eq!(
1490            dead_code_canonical_key("unused-export", &["src/utils.ts", "helper"]),
1491            "unused-export:src/utils.ts:helper"
1492        );
1493        assert_eq!(
1494            dead_code_canonical_key("unused-file", &["C:/repo/a%b.ts"]),
1495            "unused-file:C%3A/repo/a%25b.ts"
1496        );
1497        assert_ne!(
1498            dead_code_canonical_key("unused-export", &["a:b", "c"]),
1499            dead_code_canonical_key("unused-export", &["a", "b:c"])
1500        );
1501    }
1502
1503    #[test]
1504    fn the_canonical_key_and_the_id_use_the_same_parts() {
1505        let root = PathBuf::from("/repo");
1506        let paths = IdentityPaths::new(&root);
1507        let finding = member(&root, 10);
1508
1509        assert_eq!(
1510            finding.canonical_key(&paths),
1511            "unused-class-member:src/service.ts:Service:run"
1512        );
1513        assert_eq!(
1514            base_id(&finding, &paths),
1515            dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"])
1516        );
1517        assert_eq!(
1518            member(&root, 99).canonical_key(&paths),
1519            finding.canonical_key(&paths)
1520        );
1521    }
1522
1523    #[test]
1524    fn occurrence_keys_number_repeated_keys_in_input_order() {
1525        let root = PathBuf::from("/repo");
1526        let paths = IdentityPaths::new(&root);
1527        let findings = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1528
1529        assert_eq!(
1530            dead_code_occurrence_keys(&findings, &paths),
1531            vec![
1532                "unused-class-member:src/service.ts:Service:run".to_owned(),
1533                "unused-class-member:src/service.ts:Service:run:~1".to_owned(),
1534                "unused-class-member:src/service.ts:Service:run:~2".to_owned(),
1535            ]
1536        );
1537    }
1538
1539    #[test]
1540    fn the_suppression_reason_is_not_part_of_the_id() {
1541        let suppression = |reason: Option<&str>| StaleSuppression {
1542            path: PathBuf::from("src/a.ts"),
1543            line: 3,
1544            col: 0,
1545            origin: SuppressionOrigin::Comment {
1546                issue_kind: Some("unused-export".to_owned()),
1547                reason: reason.map(str::to_owned),
1548                is_file_level: false,
1549                kind_known: true,
1550            },
1551            missing_reason: false,
1552            finding_id: None,
1553            actions: Vec::new(),
1554            effective_severity: None,
1555        };
1556        let mut results = AnalysisResults {
1557            stale_suppressions: vec![suppression(None)],
1558            ..AnalysisResults::default()
1559        };
1560        let mut with_reason = AnalysisResults {
1561            stale_suppressions: vec![suppression(Some("kept for the plugin API"))],
1562            ..AnalysisResults::default()
1563        };
1564
1565        stamp_dead_code_finding_ids(&mut results, Path::new(""));
1566        stamp_dead_code_finding_ids(&mut with_reason, Path::new(""));
1567
1568        assert_eq!(
1569            ids(&results.stale_suppressions),
1570            ids(&with_reason.stale_suppressions)
1571        );
1572        assert!(
1573            ids(&results.stale_suppressions)[0]
1574                .as_deref()
1575                .is_some_and(|id| id.starts_with("dc1:stale-suppression:"))
1576        );
1577    }
1578}