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