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