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        suppression_count: _,
498        unused_component_props_exempted: _,
499        active_suppressions: _,
500        feature_flags: _,
501        export_usages: _,
502        entry_point_summary: _,
503        render_fan_in: _,
504        react_component_intel: _,
505        semantic_framework_contracts: _,
506    } = results;
507
508    visitor.visit(unused_files);
509    visitor.visit(unused_exports);
510    visitor.visit(unused_types);
511    visitor.visit(private_type_leaks);
512    visitor.visit(deprecated_exports_in_use);
513    visitor.visit(unused_dependencies);
514    visitor.visit(unused_dev_dependencies);
515    visitor.visit(unused_optional_dependencies);
516    visitor.visit(unused_enum_members);
517    visitor.visit(unused_class_members);
518    visitor.visit(unused_store_members);
519    visitor.visit(unresolved_imports);
520    visitor.visit(unlisted_dependencies);
521    visitor.visit(duplicate_exports);
522    visitor.visit(type_only_dependencies);
523    visitor.visit(test_only_dependencies);
524    visitor.visit(dev_dependencies_in_production);
525    visitor.visit(circular_dependencies);
526    visitor.visit(package_cycles);
527    visitor.visit(re_export_cycles);
528    visitor.visit(boundary_violations);
529    visitor.visit(boundary_coverage_violations);
530    visitor.visit(boundary_call_violations);
531    visitor.visit(policy_violations);
532    visitor.visit(stale_suppressions);
533    visitor.visit(unused_catalog_entries);
534    visitor.visit(empty_catalog_groups);
535    visitor.visit(unresolved_catalog_references);
536    visitor.visit(unused_dependency_overrides);
537    visitor.visit(misconfigured_dependency_overrides);
538    visitor.visit(invalid_client_exports);
539    visitor.visit(mixed_client_server_barrels);
540    visitor.visit(misplaced_directives);
541    visitor.visit(unprovided_injects);
542    visitor.visit(unrendered_components);
543    visitor.visit(route_collisions);
544    visitor.visit(dynamic_segment_name_conflicts);
545    visitor.visit(unused_component_props);
546    visitor.visit(absent_component_props);
547    visitor.visit(unused_component_emits);
548    visitor.visit(unused_component_inputs);
549    visitor.visit(unused_component_outputs);
550    visitor.visit(unused_svelte_events);
551    visitor.visit(unused_server_actions);
552    visitor.visit(unused_load_data_keys);
553    visitor.visit(prop_drilling_chains);
554    visitor.visit(thin_wrappers);
555    visitor.visit(duplicate_prop_shapes);
556}
557
558fn apply<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>, mode: StampMode) {
559    match mode {
560        StampMode::All => stamp(findings, paths),
561        StampMode::Missing => stamp_missing(findings, paths),
562    }
563}
564
565fn base_id<T: IdentifiedFinding>(finding: &T, paths: &IdentityPaths<'_>) -> String {
566    let parts = finding.identity_parts(paths);
567    let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
568    dead_code_finding_id(finding.rule_token(), &parts)
569}
570
571/// The order of findings that share a base id: position first, then the
572/// serialized finding, so the order does not depend on the input order.
573fn tiebreak_key<T: IdentifiedFinding>(finding: &T) -> ((u32, u32, u32), String) {
574    (
575        finding.tiebreak_position(),
576        serde_json::to_string(finding).unwrap_or_default(),
577    )
578}
579
580/// Stamp one finding family. The result does not depend on the input order.
581fn stamp<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
582    let mut groups: FxHashMap<String, Vec<usize>> = FxHashMap::default();
583    for (index, finding) in findings.iter_mut().enumerate() {
584        finding.set_finding_id(None);
585        groups
586            .entry(base_id(finding, paths))
587            .or_default()
588            .push(index);
589    }
590    for (base, mut members) in groups {
591        if members.len() > 1 {
592            members.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
593        }
594        for (position, index) in members.into_iter().enumerate() {
595            let id = if position == 0 {
596                base.clone()
597            } else {
598                format!("{base}~{position}")
599            };
600            findings[index].set_finding_id(Some(id));
601        }
602    }
603}
604
605/// Give an id to each finding of one family that has none.
606fn stamp_missing<T: IdentifiedFinding>(findings: &mut [T], paths: &IdentityPaths<'_>) {
607    let mut missing: Vec<usize> = (0..findings.len())
608        .filter(|&index| findings[index].finding_id().is_none())
609        .collect();
610    if missing.is_empty() {
611        return;
612    }
613    let mut taken: FxHashSet<String> = findings
614        .iter()
615        .filter_map(|finding| finding.finding_id().map(str::to_owned))
616        .collect();
617    missing.sort_by_cached_key(|&index| tiebreak_key(&findings[index]));
618    for index in missing {
619        let base = base_id(&findings[index], paths);
620        let mut id = base.clone();
621        let mut suffix = 0_usize;
622        while taken.contains(&id) {
623            suffix += 1;
624            id = format!("{base}~{suffix}");
625        }
626        taken.insert(id.clone());
627        findings[index].set_finding_id(Some(id));
628    }
629}
630
631/// Implement [`IdentifiedFinding`] for a type with a `finding_id` field.
632macro_rules! identified {
633    (
634        $ty:ty,
635        token: |$t:ident| $token:expr,
636        parts: |$f:ident, $p:ident| $parts:expr,
637        position: |$g:ident| $position:expr $(,)?
638    ) => {
639        impl IdentifiedFinding for $ty {
640            fn rule_token(&self) -> &'static str {
641                let $t = self;
642                $token
643            }
644
645            fn identity_parts(&self, $p: &IdentityPaths<'_>) -> Vec<String> {
646                let $f = self;
647                $parts
648            }
649
650            fn tiebreak_position(&self) -> (u32, u32, u32) {
651                let $g = self;
652                $position
653            }
654
655            fn finding_id(&self) -> Option<&str> {
656                self.finding_id.as_deref()
657            }
658
659            fn set_finding_id(&mut self, id: Option<String>) {
660                self.finding_id = id;
661            }
662        }
663    };
664}
665
666identified!(
667    UnusedFileFinding,
668    token: |_t| "unused-file",
669    parts: |f, p| vec![p.key(&f.file.path)],
670    position: |_g| (0, 0, 0),
671);
672
673identified!(
674    UnusedExportFinding,
675    token: |_t| "unused-export",
676    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
677    position: |g| (g.export.line, g.export.col, g.export.span_start),
678);
679
680identified!(
681    UnusedTypeFinding,
682    token: |_t| "unused-type",
683    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
684    position: |g| (g.export.line, g.export.col, g.export.span_start),
685);
686
687identified!(
688    PrivateTypeLeakFinding,
689    token: |_t| "private-type-leak",
690    parts: |f, p| vec![
691        p.key(&f.leak.path),
692        f.leak.export_name.clone(),
693        f.leak.type_name.clone(),
694    ],
695    position: |g| (g.leak.line, g.leak.col, g.leak.span_start),
696);
697
698identified!(
699    DeprecatedExportInUseFinding,
700    token: |_t| "deprecated-export-in-use",
701    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
702    position: |g| (g.export.line, g.export.col, g.export.span_start),
703);
704
705identified!(
706    UnusedDependencyFinding,
707    token: |_t| "unused-dependency",
708    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
709    position: |g| (g.dep.line, 0, 0),
710);
711
712identified!(
713    UnusedDevDependencyFinding,
714    token: |_t| "unused-dev-dependency",
715    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
716    position: |g| (g.dep.line, 0, 0),
717);
718
719identified!(
720    UnusedOptionalDependencyFinding,
721    token: |_t| "unused-optional-dependency",
722    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
723    position: |g| (g.dep.line, 0, 0),
724);
725
726identified!(
727    TypeOnlyDependencyFinding,
728    token: |_t| "type-only-dependency",
729    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
730    position: |g| (g.dep.line, 0, 0),
731);
732
733identified!(
734    TestOnlyDependencyFinding,
735    token: |_t| "test-only-dependency",
736    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
737    position: |g| (g.dep.line, 0, 0),
738);
739
740identified!(
741    DevDependencyInProductionFinding,
742    token: |_t| "dev-dependency-in-production",
743    parts: |f, p| vec![p.key(&f.dep.path), f.dep.package_name.clone()],
744    position: |g| (g.dep.line, 0, 0),
745);
746
747identified!(
748    UnlistedDependencyFinding,
749    token: |_t| "unlisted-dependency",
750    parts: |f, _p| vec![f.dep.package_name.clone()],
751    position: |_g| (0, 0, 0),
752);
753
754identified!(
755    UnusedEnumMemberFinding,
756    token: |_t| "unused-enum-member",
757    parts: |f, p| member_parts(&f.member, p),
758    position: |g| (g.member.line, g.member.col, 0),
759);
760
761identified!(
762    UnusedClassMemberFinding,
763    token: |_t| "unused-class-member",
764    parts: |f, p| member_parts(&f.member, p),
765    position: |g| (g.member.line, g.member.col, 0),
766);
767
768identified!(
769    UnusedStoreMemberFinding,
770    token: |_t| "unused-store-member",
771    parts: |f, p| member_parts(&f.member, p),
772    position: |g| (g.member.line, g.member.col, 0),
773);
774
775fn member_parts(member: &crate::results::UnusedMember, paths: &IdentityPaths<'_>) -> Vec<String> {
776    vec![
777        paths.key(&member.path),
778        member.parent_name.clone(),
779        member.member_name.clone(),
780    ]
781}
782
783identified!(
784    UnresolvedImportFinding,
785    token: |_t| "unresolved-import",
786    parts: |f, p| vec![p.key(&f.import.path), f.import.specifier.clone()],
787    position: |g| (g.import.line, g.import.col, 0),
788);
789
790identified!(
791    DuplicateExportFinding,
792    token: |_t| "duplicate-export",
793    parts: |f, p| vec![
794        f.export.export_name.clone(),
795        p.set(f.export.locations.iter().map(|location| location.path.as_path())),
796    ],
797    position: |g| g
798        .export
799        .locations
800        .first()
801        .map_or((0, 0, 0), |location| (location.line, location.col, 0)),
802);
803
804identified!(
805    CircularDependencyFinding,
806    token: |_t| "circular-dependency",
807    parts: |f, p| vec![p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path))],
808    position: |g| (g.cycle.line, g.cycle.col, 0),
809);
810
811identified!(
812    PackageCycleFinding,
813    token: |_t| "package-cycle",
814    parts: |f, p| vec![p.set(f.cycle.package_roots.iter().map(std::path::PathBuf::as_path))],
815    position: |_g| (0, 0, 0),
816);
817
818identified!(
819    ReExportCycleFinding,
820    token: |_t| "re-export-cycle",
821    parts: |f, p| vec![
822        re_export_cycle_kind(f.cycle.kind).to_owned(),
823        p.set(f.cycle.files.iter().map(std::path::PathBuf::as_path)),
824    ],
825    position: |_g| (0, 0, 0),
826);
827
828/// The wire spelling of the cycle kind, so the part matches the JSON value.
829const fn re_export_cycle_kind(kind: ReExportCycleKind) -> &'static str {
830    match kind {
831        ReExportCycleKind::MultiNode => "multi-node",
832        ReExportCycleKind::SelfLoop => "self-loop",
833    }
834}
835
836identified!(
837    BoundaryViolationFinding,
838    token: |_t| "boundary-violation",
839    parts: |f, p| vec![p.key(&f.violation.from_path), p.key(&f.violation.to_path)],
840    position: |g| (g.violation.line, g.violation.col, 0),
841);
842
843identified!(
844    BoundaryCoverageViolationFinding,
845    token: |_t| "boundary-coverage",
846    parts: |f, p| vec![p.key(&f.violation.path)],
847    position: |g| (g.violation.line, g.violation.col, 0),
848);
849
850identified!(
851    BoundaryCallViolationFinding,
852    token: |_t| "boundary-call-violation",
853    parts: |f, p| vec![p.key(&f.violation.path), f.violation.callee.clone()],
854    position: |g| (g.violation.line, g.violation.col, 0),
855);
856
857identified!(
858    PolicyViolationFinding,
859    token: |_t| "policy-violation",
860    parts: |f, p| vec![
861        p.key(&f.violation.path),
862        f.violation.pack.clone(),
863        f.violation.rule_id.clone(),
864        f.violation.matched.clone(),
865    ],
866    position: |g| (g.violation.line, g.violation.col, 0),
867);
868
869identified!(
870    StaleSuppression,
871    token: |t| if t.missing_reason {
872        "missing-suppression-reason"
873    } else {
874        "stale-suppression"
875    },
876    parts: |f, p| suppression_parts(f, p),
877    position: |g| (g.line, g.col, 0),
878);
879
880/// Path, origin kind, issue kind (or `*`), and scope or export name. The
881/// reason text is not a part: adding a reason must not change the id.
882fn suppression_parts(suppression: &StaleSuppression, paths: &IdentityPaths<'_>) -> Vec<String> {
883    let path = paths.key(&suppression.path);
884    match &suppression.origin {
885        SuppressionOrigin::Comment {
886            issue_kind,
887            is_file_level,
888            ..
889        } => vec![
890            path,
891            "comment".to_owned(),
892            issue_kind.clone().unwrap_or_else(|| ANY_KIND.to_owned()),
893            if *is_file_level { "file" } else { "line" }.to_owned(),
894        ],
895        SuppressionOrigin::JsdocTag { export_name, .. } => vec![
896            path,
897            "jsdoc_tag".to_owned(),
898            ANY_KIND.to_owned(),
899            export_name.clone(),
900        ],
901    }
902}
903
904identified!(
905    UnusedCatalogEntryFinding,
906    token: |_t| "unused-catalog-entry",
907    parts: |f, p| vec![
908        p.key(&f.entry.path),
909        f.entry.catalog_name.clone(),
910        f.entry.entry_name.clone(),
911    ],
912    position: |g| (g.entry.line, 0, 0),
913);
914
915identified!(
916    EmptyCatalogGroupFinding,
917    token: |_t| "empty-catalog-group",
918    parts: |f, p| vec![p.key(&f.group.path), f.group.catalog_name.clone()],
919    position: |g| (g.group.line, 0, 0),
920);
921
922identified!(
923    UnresolvedCatalogReferenceFinding,
924    token: |_t| "unresolved-catalog-reference",
925    parts: |f, p| vec![
926        p.key(&f.reference.path),
927        f.reference.catalog_name.clone(),
928        f.reference.entry_name.clone(),
929    ],
930    position: |g| (g.reference.line, 0, 0),
931);
932
933identified!(
934    UnusedDependencyOverrideFinding,
935    token: |_t| "unused-dependency-override",
936    parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
937    position: |g| (g.entry.line, 0, 0),
938);
939
940identified!(
941    MisconfiguredDependencyOverrideFinding,
942    token: |_t| "misconfigured-dependency-override",
943    parts: |f, _p| vec![override_source(f.entry.source).to_owned(), f.entry.raw_key.clone()],
944    position: |g| (g.entry.line, 0, 0),
945);
946
947/// The wire spelling of the override source, so the part matches the JSON
948/// value.
949const fn override_source(source: DependencyOverrideSource) -> &'static str {
950    match source {
951        DependencyOverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml",
952        DependencyOverrideSource::PnpmPackageJson => "package.json",
953    }
954}
955
956identified!(
957    InvalidClientExportFinding,
958    token: |_t| "invalid-client-export",
959    parts: |f, p| vec![p.key(&f.export.path), f.export.export_name.clone()],
960    position: |g| (g.export.line, g.export.col, 0),
961);
962
963identified!(
964    MixedClientServerBarrelFinding,
965    token: |_t| "mixed-client-server-barrel",
966    parts: |f, p| vec![p.key(&f.barrel.path)],
967    position: |g| (g.barrel.line, g.barrel.col, 0),
968);
969
970identified!(
971    MisplacedDirectiveFinding,
972    token: |_t| "misplaced-directive",
973    parts: |f, p| vec![
974        p.key(&f.directive_site.path),
975        f.directive_site.directive.clone(),
976    ],
977    position: |g| (g.directive_site.line, g.directive_site.col, 0),
978);
979
980identified!(
981    UnprovidedInjectFinding,
982    token: |_t| "unprovided-inject",
983    parts: |f, p| vec![p.key(&f.inject.path), f.inject.key_name.clone()],
984    position: |g| (g.inject.line, g.inject.col, 0),
985);
986
987identified!(
988    UnrenderedComponentFinding,
989    token: |_t| "unrendered-component",
990    parts: |f, p| vec![p.key(&f.component.path), f.component.component_name.clone()],
991    position: |g| (g.component.line, g.component.col, 0),
992);
993
994identified!(
995    RouteCollisionFinding,
996    token: |_t| "route-collision",
997    parts: |f, p| vec![p.key(&f.collision.path), f.collision.url.clone()],
998    position: |g| (g.collision.line, g.collision.col, 0),
999);
1000
1001identified!(
1002    DynamicSegmentNameConflictFinding,
1003    token: |_t| "dynamic-segment-name-conflict",
1004    parts: |f, p| vec![p.key(&f.conflict.path), f.conflict.position.clone()],
1005    position: |g| (g.conflict.line, g.conflict.col, 0),
1006);
1007
1008identified!(
1009    UnusedComponentPropFinding,
1010    token: |_t| "unused-component-prop",
1011    parts: |f, p| vec![
1012        p.key(&f.prop.path),
1013        f.prop.component_name.clone(),
1014        f.prop.prop_name.clone(),
1015    ],
1016    position: |g| (g.prop.line, g.prop.col, 0),
1017);
1018identified!(
1019    AbsentComponentPropFinding,
1020    token: |_t| "absent-component-prop",
1021    parts: |f, p| vec![
1022        p.key(&f.prop.path),
1023        f.prop.component_name.clone(),
1024        f.prop.prop_name.clone(),
1025    ],
1026    position: |g| (g.prop.line, g.prop.col, 0),
1027);
1028
1029identified!(
1030    UnusedComponentEmitFinding,
1031    token: |_t| "unused-component-emit",
1032    parts: |f, p| vec![
1033        p.key(&f.emit.path),
1034        f.emit.component_name.clone(),
1035        f.emit.emit_name.clone(),
1036    ],
1037    position: |g| (g.emit.line, g.emit.col, 0),
1038);
1039
1040identified!(
1041    UnusedComponentInputFinding,
1042    token: |_t| "unused-component-input",
1043    parts: |f, p| vec![
1044        p.key(&f.input.path),
1045        f.input.component_name.clone(),
1046        f.input.input_name.clone(),
1047    ],
1048    position: |g| (g.input.line, g.input.col, 0),
1049);
1050
1051identified!(
1052    UnusedComponentOutputFinding,
1053    token: |_t| "unused-component-output",
1054    parts: |f, p| vec![
1055        p.key(&f.output.path),
1056        f.output.component_name.clone(),
1057        f.output.output_name.clone(),
1058    ],
1059    position: |g| (g.output.line, g.output.col, 0),
1060);
1061
1062identified!(
1063    UnusedSvelteEventFinding,
1064    token: |_t| "unused-svelte-event",
1065    parts: |f, p| vec![
1066        p.key(&f.event.path),
1067        f.event.component_name.clone(),
1068        f.event.event_name.clone(),
1069    ],
1070    position: |g| (g.event.line, g.event.col, 0),
1071);
1072
1073identified!(
1074    UnusedServerActionFinding,
1075    token: |_t| "unused-server-action",
1076    parts: |f, p| vec![p.key(&f.action.path), f.action.action_name.clone()],
1077    position: |g| (g.action.line, g.action.col, 0),
1078);
1079
1080identified!(
1081    UnusedLoadDataKeyFinding,
1082    token: |_t| "unused-load-data-key",
1083    parts: |f, p| vec![p.key(&f.key.path), f.key.key_name.clone()],
1084    position: |g| (g.key.line, g.key.col, 0),
1085);
1086
1087identified!(
1088    PropDrillingChainFinding,
1089    token: |_t| "prop-drilling",
1090    parts: |f, p| {
1091        let origin = f.chain.hops.first();
1092        vec![
1093            origin.map(|hop| p.key(&hop.file)).unwrap_or_default(),
1094            origin.map(|hop| hop.component.clone()).unwrap_or_default(),
1095            f.chain.prop.clone(),
1096        ]
1097    },
1098    position: |g| (g.chain.hops.first().map_or(0, |hop| hop.line), 0, 0),
1099);
1100
1101identified!(
1102    ThinWrapperFinding,
1103    token: |_t| "thin-wrapper",
1104    parts: |f, p| vec![p.key(&f.wrapper.file), f.wrapper.component.clone()],
1105    position: |g| (g.wrapper.line, 0, 0),
1106);
1107
1108identified!(
1109    DuplicatePropShapeFinding,
1110    token: |_t| "duplicate-prop-shape",
1111    parts: |f, p| vec![p.key(&f.shape.file), f.shape.component.clone()],
1112    position: |g| (g.shape.line, 0, 0),
1113);
1114
1115#[cfg(test)]
1116mod tests {
1117    use std::path::PathBuf;
1118
1119    use super::*;
1120    use crate::results::{UnusedExport, UnusedFile, UnusedMember};
1121
1122    // Every expected value below comes from an independent FNV-1a 64 script,
1123    // not from this module.
1124
1125    #[test]
1126    fn fnv1a64_parts_golden_values() {
1127        assert_eq!(
1128            fnv1a64_parts(&["src/index.ts", "FEATURE_X", "3"]),
1129            "2278c9d9bd9d2dd2"
1130        );
1131        assert_eq!(
1132            fnv1a64_parts(&["fallow/unused-file", "src/orphan.ts"]),
1133            "c03b925ddb7d871a"
1134        );
1135        assert_eq!(fnv1a64_parts(&[]), "cbf29ce484222325");
1136    }
1137
1138    #[test]
1139    fn fnv1a64_hex_golden_value() {
1140        assert_eq!(
1141            fnv1a64_hex(b"security/client-server-leak:src/a.ts:3:7"),
1142            "89b6dece9d8b96d2"
1143        );
1144    }
1145
1146    #[test]
1147    fn finding_id_syntax_accepts_base_and_tiebreak_ids() {
1148        assert!(is_dead_code_finding_id(
1149            "dc1:unused-export:81a349a3b9ea3b15"
1150        ));
1151        assert!(is_dead_code_finding_id(
1152            "dc1:unused-class-member:0123456789abcdef~1"
1153        ));
1154        assert!(is_dead_code_finding_id(
1155            "dc1:unused-file:0123456789abcdef~12"
1156        ));
1157    }
1158
1159    #[test]
1160    fn finding_id_syntax_refuses_other_shapes() {
1161        for bad in [
1162            "",
1163            "dc1",
1164            "dc1:unused-export",
1165            "dc1::0123456789abcdef",
1166            "dc2:unused-export:0123456789abcdef",
1167            "dc1:unused-export:0123456789ABCDEF",
1168            "dc1:unused-export:0123456789abcde",
1169            "dc1:unused-export:0123456789abcdef0",
1170            "dc1:unused-export:0123456789abcdef~",
1171            "dc1:unused-export:0123456789abcdef~0",
1172            "dc1:unused-export:0123456789abcdef~01",
1173            "dc1:unused-export:0123456789abcdef~x",
1174            "dc1:Unused-Export:0123456789abcdef",
1175            "dc1:unused-export:0123456789abcdef:extra",
1176        ] {
1177            assert!(!is_dead_code_finding_id(bad), "{bad:?} was accepted");
1178        }
1179    }
1180
1181    #[test]
1182    fn retain_by_id_keeps_only_the_requested_findings() {
1183        let root = PathBuf::from("/repo");
1184        let mut results = AnalysisResults {
1185            unused_files: vec![
1186                UnusedFileFinding::with_actions(UnusedFile {
1187                    path: root.join("src/a.ts"),
1188                }),
1189                UnusedFileFinding::with_actions(UnusedFile {
1190                    path: root.join("src/b.ts"),
1191                }),
1192            ],
1193            ..AnalysisResults::default()
1194        };
1195        stamp_dead_code_finding_ids(&mut results, &root);
1196        let kept = results.unused_files[1]
1197            .finding_id
1198            .clone()
1199            .expect("stamped id");
1200        let ids: FxHashSet<String> = [kept.clone(), "dc1:unused-file:0000000000000000".to_owned()]
1201            .into_iter()
1202            .collect();
1203
1204        let present = present_dead_code_finding_ids(&mut results, &ids);
1205        assert_eq!(results.unused_files.len(), 2, "present only reads");
1206        let matched = retain_dead_code_findings_by_id(&mut results, &ids);
1207
1208        assert_eq!(present, matched);
1209        assert_eq!(matched, std::iter::once(kept.clone()).collect());
1210        assert_eq!(results.unused_files.len(), 1);
1211        assert_eq!(
1212            results.unused_files[0].finding_id.as_deref(),
1213            Some(kept.as_str())
1214        );
1215    }
1216
1217    #[test]
1218    fn dead_code_finding_id_golden_values() {
1219        assert_eq!(
1220            dead_code_finding_id("unused-export", &["src/utils.ts", "helper"]),
1221            "dc1:unused-export:81a349a3b9ea3b15"
1222        );
1223        assert_eq!(
1224            dead_code_finding_id("unused-file", &["src/orphan.ts"]),
1225            "dc1:unused-file:9fd2d414a2a9e611"
1226        );
1227        assert_eq!(
1228            dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"]),
1229            "dc1:unused-class-member:675fa79a4c2f244f"
1230        );
1231        assert_eq!(
1232            dead_code_finding_id("unused-dependency", &["package.json", "lodash"]),
1233            "dc1:unused-dependency:e2f217ff5a209568"
1234        );
1235        assert_eq!(
1236            dead_code_finding_id("duplicate-export", &["Button", "src/a.ts|src/b.ts"]),
1237            "dc1:duplicate-export:17c140e16d40660a"
1238        );
1239    }
1240
1241    fn export(root: &Path, name: &str, line: u32) -> UnusedExportFinding {
1242        UnusedExportFinding::with_actions(UnusedExport {
1243            path: root.join("src/utils.ts"),
1244            export_name: name.to_owned(),
1245            is_type_only: false,
1246            line,
1247            col: 7,
1248            span_start: line * 10,
1249            is_re_export: false,
1250            deprecated: false,
1251            deprecated_reason: None,
1252        })
1253    }
1254
1255    fn member(root: &Path, line: u32) -> UnusedClassMemberFinding {
1256        UnusedClassMemberFinding::with_actions(UnusedMember {
1257            path: root.join("src/service.ts"),
1258            parent_name: "Service".to_owned(),
1259            member_name: "run".to_owned(),
1260            kind: crate::extract::MemberKind::ClassMethod,
1261            line,
1262            col: 2,
1263        })
1264    }
1265
1266    fn ids<T: IdentifiedFinding>(findings: &[T]) -> Vec<Option<String>> {
1267        findings
1268            .iter()
1269            .map(|finding| finding.finding_id().map(str::to_owned))
1270            .collect()
1271    }
1272
1273    #[test]
1274    fn stamping_uses_root_relative_paths_and_ignores_lines() {
1275        let root = PathBuf::from("/repo");
1276        let mut results = AnalysisResults {
1277            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1278                path: root.join("src/orphan.ts"),
1279            })],
1280            unused_exports: vec![export(&root, "helper", 40)],
1281            ..AnalysisResults::default()
1282        };
1283
1284        stamp_dead_code_finding_ids(&mut results, &root);
1285
1286        assert_eq!(
1287            ids(&results.unused_files),
1288            vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1289        );
1290        assert_eq!(
1291            ids(&results.unused_exports),
1292            vec![Some("dc1:unused-export:81a349a3b9ea3b15".to_owned())]
1293        );
1294    }
1295
1296    #[test]
1297    fn duplicate_subjects_get_a_tiebreak_suffix_in_line_order() {
1298        let root = PathBuf::from("/repo");
1299        let mut results = AnalysisResults {
1300            unused_class_members: vec![member(&root, 30), member(&root, 10), member(&root, 20)],
1301            ..AnalysisResults::default()
1302        };
1303
1304        stamp_dead_code_finding_ids(&mut results, &root);
1305
1306        let base = "dc1:unused-class-member:675fa79a4c2f244f";
1307        assert_eq!(
1308            ids(&results.unused_class_members),
1309            vec![
1310                Some(format!("{base}~2")),
1311                Some(base.to_owned()),
1312                Some(format!("{base}~1")),
1313            ]
1314        );
1315    }
1316
1317    #[test]
1318    fn stamping_does_not_depend_on_input_order() {
1319        let root = PathBuf::from("/repo");
1320        let forward = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1321        let mut reversed = forward.clone();
1322        reversed.reverse();
1323        let mut a = AnalysisResults {
1324            unused_class_members: forward,
1325            ..AnalysisResults::default()
1326        };
1327        let mut b = AnalysisResults {
1328            unused_class_members: reversed,
1329            ..AnalysisResults::default()
1330        };
1331
1332        stamp_dead_code_finding_ids(&mut a, &root);
1333        stamp_dead_code_finding_ids(&mut b, &root);
1334
1335        let mut a_pairs: Vec<(u32, Option<String>)> = a
1336            .unused_class_members
1337            .iter()
1338            .map(|finding| (finding.member.line, finding.finding_id.clone()))
1339            .collect();
1340        let mut b_pairs: Vec<(u32, Option<String>)> = b
1341            .unused_class_members
1342            .iter()
1343            .map(|finding| (finding.member.line, finding.finding_id.clone()))
1344            .collect();
1345        a_pairs.sort();
1346        b_pairs.sort();
1347        assert_eq!(a_pairs, b_pairs);
1348    }
1349
1350    #[test]
1351    fn stamping_twice_gives_the_same_ids() {
1352        let root = PathBuf::from("/repo");
1353        let mut results = AnalysisResults {
1354            unused_class_members: vec![member(&root, 10), member(&root, 20)],
1355            unused_exports: vec![export(&root, "helper", 3)],
1356            ..AnalysisResults::default()
1357        };
1358
1359        stamp_dead_code_finding_ids(&mut results, &root);
1360        let first = (
1361            ids(&results.unused_class_members),
1362            ids(&results.unused_exports),
1363        );
1364        stamp_dead_code_finding_ids(&mut results, &root);
1365
1366        assert_eq!(
1367            first,
1368            (
1369                ids(&results.unused_class_members),
1370                ids(&results.unused_exports),
1371            )
1372        );
1373    }
1374
1375    #[test]
1376    fn stamping_missing_ids_keeps_existing_ids_and_takes_a_free_suffix() {
1377        let root = PathBuf::from("/repo");
1378        let mut results = AnalysisResults {
1379            unused_class_members: vec![member(&root, 10), member(&root, 20)],
1380            ..AnalysisResults::default()
1381        };
1382        stamp_dead_code_finding_ids(&mut results, &root);
1383        // A filter removed the base finding; a later stage adds a new one.
1384        results.unused_class_members.remove(0);
1385        results.unused_class_members.push(member(&root, 5));
1386
1387        stamp_missing_dead_code_finding_ids(&mut results, &root);
1388
1389        let base = "dc1:unused-class-member:675fa79a4c2f244f";
1390        assert_eq!(
1391            ids(&results.unused_class_members),
1392            vec![Some(format!("{base}~1")), Some(base.to_owned())]
1393        );
1394    }
1395
1396    fn duplicate_export(root: &Path, files: &[&str]) -> DuplicateExportFinding {
1397        DuplicateExportFinding::with_actions(crate::results::DuplicateExport {
1398            export_name: "Button".to_owned(),
1399            locations: files
1400                .iter()
1401                .map(|file| crate::results::DuplicateLocation {
1402                    path: root.join(file),
1403                    line: 1,
1404                    col: 0,
1405                })
1406                .collect(),
1407        })
1408    }
1409
1410    #[test]
1411    fn a_path_set_without_special_characters_keeps_its_golden_id() {
1412        let root = PathBuf::from("/repo");
1413        let mut results = AnalysisResults {
1414            duplicate_exports: vec![duplicate_export(&root, &["src/b.ts", "src/a.ts"])],
1415            ..AnalysisResults::default()
1416        };
1417
1418        stamp_dead_code_finding_ids(&mut results, &root);
1419
1420        assert_eq!(
1421            ids(&results.duplicate_exports),
1422            vec![Some("dc1:duplicate-export:17c140e16d40660a".to_owned())]
1423        );
1424    }
1425
1426    #[test]
1427    fn a_pipe_in_a_file_name_does_not_collide_with_two_files() {
1428        let root = PathBuf::from("/repo");
1429        let paths = IdentityPaths::new(&root);
1430        let one = root.join("src/a.ts|src/b.ts");
1431        let first = root.join("src/a.ts");
1432        let second = root.join("src/b.ts");
1433
1434        assert_eq!(paths.set([one.as_path()]), "src/a.ts%7Csrc/b.ts");
1435        assert_eq!(
1436            paths.set([first.as_path(), second.as_path()]),
1437            "src/a.ts|src/b.ts"
1438        );
1439        assert_eq!(paths.set([root.join("100%.ts").as_path()]), "100%25.ts");
1440
1441        let mut results = AnalysisResults {
1442            duplicate_exports: vec![
1443                duplicate_export(&root, &["src/a.ts|src/b.ts"]),
1444                duplicate_export(&root, &["src/a.ts", "src/b.ts"]),
1445            ],
1446            ..AnalysisResults::default()
1447        };
1448        stamp_dead_code_finding_ids(&mut results, &root);
1449
1450        let stamped = ids(&results.duplicate_exports);
1451        assert_ne!(stamped[0], stamped[1]);
1452        assert!(
1453            stamped.iter().flatten().all(|id| !id.contains('~')),
1454            "the two findings must not share a base id: {stamped:?}"
1455        );
1456    }
1457
1458    #[test]
1459    fn a_package_cycle_gets_a_golden_id_from_its_sorted_package_roots() {
1460        let root = PathBuf::from("/repo");
1461        let cycle = |roots: &[&str]| {
1462            PackageCycleFinding::with_actions(crate::results::PackageCycle {
1463                packages: vec!["@x/a".to_owned(), "@x/b".to_owned()],
1464                package_roots: roots.iter().map(|dir| root.join(dir)).collect(),
1465                length: 2,
1466                edges: Vec::new(),
1467                group_truncated: false,
1468            })
1469        };
1470        let mut results = AnalysisResults {
1471            package_cycles: vec![cycle(&["packages/b", "packages/a"])],
1472            ..AnalysisResults::default()
1473        };
1474
1475        stamp_dead_code_finding_ids(&mut results, &root);
1476
1477        assert_eq!(
1478            ids(&results.package_cycles),
1479            vec![Some("dc1:package-cycle:fed127e4525389ac".to_owned())]
1480        );
1481    }
1482
1483    #[test]
1484    fn windows_separators_give_the_same_id() {
1485        let mut windows = AnalysisResults {
1486            unused_files: vec![UnusedFileFinding::with_actions(UnusedFile {
1487                path: PathBuf::from("src\\orphan.ts"),
1488            })],
1489            ..AnalysisResults::default()
1490        };
1491
1492        stamp_dead_code_finding_ids(&mut windows, Path::new(""));
1493
1494        assert_eq!(
1495            ids(&windows.unused_files),
1496            vec![Some("dc1:unused-file:9fd2d414a2a9e611".to_owned())]
1497        );
1498    }
1499
1500    #[test]
1501    fn canonical_keys_are_readable_and_escape_the_separator() {
1502        assert_eq!(
1503            dead_code_canonical_key("unused-export", &["src/utils.ts", "helper"]),
1504            "unused-export:src/utils.ts:helper"
1505        );
1506        assert_eq!(
1507            dead_code_canonical_key("unused-file", &["C:/repo/a%b.ts"]),
1508            "unused-file:C%3A/repo/a%25b.ts"
1509        );
1510        assert_ne!(
1511            dead_code_canonical_key("unused-export", &["a:b", "c"]),
1512            dead_code_canonical_key("unused-export", &["a", "b:c"])
1513        );
1514    }
1515
1516    #[test]
1517    fn the_canonical_key_and_the_id_use_the_same_parts() {
1518        let root = PathBuf::from("/repo");
1519        let paths = IdentityPaths::new(&root);
1520        let finding = member(&root, 10);
1521
1522        assert_eq!(
1523            finding.canonical_key(&paths),
1524            "unused-class-member:src/service.ts:Service:run"
1525        );
1526        assert_eq!(
1527            base_id(&finding, &paths),
1528            dead_code_finding_id("unused-class-member", &["src/service.ts", "Service", "run"])
1529        );
1530        assert_eq!(
1531            member(&root, 99).canonical_key(&paths),
1532            finding.canonical_key(&paths)
1533        );
1534    }
1535
1536    #[test]
1537    fn occurrence_keys_number_repeated_keys_in_input_order() {
1538        let root = PathBuf::from("/repo");
1539        let paths = IdentityPaths::new(&root);
1540        let findings = vec![member(&root, 10), member(&root, 20), member(&root, 30)];
1541
1542        assert_eq!(
1543            dead_code_occurrence_keys(&findings, &paths),
1544            vec![
1545                "unused-class-member:src/service.ts:Service:run".to_owned(),
1546                "unused-class-member:src/service.ts:Service:run:~1".to_owned(),
1547                "unused-class-member:src/service.ts:Service:run:~2".to_owned(),
1548            ]
1549        );
1550    }
1551
1552    #[test]
1553    fn the_suppression_reason_is_not_part_of_the_id() {
1554        let suppression = |reason: Option<&str>| StaleSuppression {
1555            path: PathBuf::from("src/a.ts"),
1556            line: 3,
1557            col: 0,
1558            origin: SuppressionOrigin::Comment {
1559                issue_kind: Some("unused-export".to_owned()),
1560                reason: reason.map(str::to_owned),
1561                is_file_level: false,
1562                kind_known: true,
1563            },
1564            missing_reason: false,
1565            finding_id: None,
1566            actions: Vec::new(),
1567            effective_severity: None,
1568        };
1569        let mut results = AnalysisResults {
1570            stale_suppressions: vec![suppression(None)],
1571            ..AnalysisResults::default()
1572        };
1573        let mut with_reason = AnalysisResults {
1574            stale_suppressions: vec![suppression(Some("kept for the plugin API"))],
1575            ..AnalysisResults::default()
1576        };
1577
1578        stamp_dead_code_finding_ids(&mut results, Path::new(""));
1579        stamp_dead_code_finding_ids(&mut with_reason, Path::new(""));
1580
1581        assert_eq!(
1582            ids(&results.stale_suppressions),
1583            ids(&with_reason.stale_suppressions)
1584        );
1585        assert!(
1586            ids(&results.stale_suppressions)[0]
1587                .as_deref()
1588                .is_some_and(|id| id.starts_with("dc1:stale-suppression:"))
1589        );
1590    }
1591}