Skip to main content

codehelion_core/
stable_id.rs

1//! Stable, position-free identifiers for clone-audit entities.
2//!
3//! Audit continuity across scans hinges on identifiers that survive the edits
4//! they should survive: unrelated changes elsewhere in the file, formatting
5//! and comments, and file moves must not change an identifier, while a change
6//! to the identified content must. Every identifier here is therefore a
7//! 128-bit BLAKE3 digest of *content and analysis context only* — line
8//! numbers, byte offsets, file paths, token indices and input ordering are
9//! never hashed. Reporting positions live in anchors (see
10//! [`Instance`](crate::engine::Instance)), which are carried next to the
11//! identifiers and updated freely on re-scan.
12//!
13//! The identifier kinds are distinct newtypes, so a unit fingerprint can
14//! never be passed where a group fingerprint is expected; the confusion is a
15//! compile error rather than a silent mismatch. Each kind also hashes under
16//! its own domain tag, so equal content in different roles yields unrelated
17//! digests.
18//!
19//! Hash inputs are length-prefixed and include the schema version, the
20//! normalization ruleset version, the frontend version, the analysis mode,
21//! the language and the build variant, so results from incompatible
22//! configurations never collide silently. 128 bits are persisted because
23//! these are long-lived identity keys: a truncated key that collides would
24//! fuse two clones' histories without any symptom.
25
26use core::fmt;
27
28use crate::clone_class::CloneClass;
29use crate::discovery::{BuildVariant, Language};
30use crate::engine::normalize::{self, LiteralNorm, NormAtom, Resolution};
31use crate::engine::{EngineReport, InputFile};
32use crate::frontend::Token;
33use crate::semantic::{SOG_SCHEMA_VERSION, SemanticOperationGraph};
34
35/// Version of the identifier-hashing recipe. Bump on any change to the hash
36/// inputs, their encoding or their order.
37pub const FP_SCHEMA_VERSION: &str = "fp-schema-v1";
38
39/// The hash algorithm behind every identifier, recorded so a future
40/// algorithm change is an explicit versioned event rather than a silent one.
41pub const HASH_ALGORITHM: &str = "blake3-128";
42
43macro_rules! stable_id {
44    ($(#[$doc:meta])* $name:ident) => {
45        $(#[$doc])*
46        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47        pub struct $name([u8; 16]);
48
49        impl $name {
50            /// Wrap identifier bytes produced earlier by this tool (for
51            /// example, loaded back from the store).
52            #[must_use]
53            pub const fn from_bytes(bytes: [u8; 16]) -> Self {
54                Self(bytes)
55            }
56
57            /// The identifier's raw bytes.
58            #[must_use]
59            pub const fn as_bytes(&self) -> &[u8; 16] {
60                &self.0
61            }
62
63            /// Lowercase hex form used in reports.
64            #[must_use]
65            pub fn to_hex(&self) -> String {
66                self.to_string()
67            }
68        }
69
70        impl fmt::Display for $name {
71            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72                for byte in self.0 {
73                    write!(f, "{byte:02x}")?;
74                }
75                Ok(())
76            }
77        }
78    };
79}
80
81stable_id!(
82    /// Fingerprint of one whole code unit (function, method, impl/record
83    /// block, closure), hashed from its token content.
84    UnitFingerprint
85);
86stable_id!(
87    /// Fingerprint of a sub-unit content slice: a candidate fragment or a
88    /// matched run. Content-identical slices share one fingerprint;
89    /// occurrences are told apart by [`FindingId`], never by position.
90    FragmentFingerprint
91);
92stable_id!(
93    /// Fingerprint of a clone group, derived order-independently from the
94    /// deduplicated content fingerprints of its members.
95    CloneGroupFingerprint
96);
97stable_id!(
98    /// Stable identity of a clone group's history across fingerprint changes.
99    ///
100    /// A lineage begins from a group fingerprint but has its own hash domain;
101    /// later runs may adopt it through explicit, recorded overlap evidence.
102    GroupLineageId
103);
104stable_id!(
105    /// Identifier of one finding: a specific occurrence of a group's content,
106    /// discriminated by its host unit and an in-host occurrence rank rather
107    /// than by any source position.
108    FindingId
109);
110stable_id!(
111    /// Identity of an opt-in comparison across distinct build variants.
112    ///
113    /// This deliberately lives outside every normal scan and clone-group
114    /// domain: a cross-variant result is not a new build variant.
115    CrossVariantComparisonId
116);
117stable_id!(
118    /// Stable identity of one group found by a cross-variant comparison.
119    CrossVariantGroupId
120);
121stable_id!(
122    /// Position-free identity of one cross-variant group occurrence.
123    CrossVariantMemberId
124);
125stable_id!(
126    /// Identity of an explicitly requested Rust-to-C++ semantic comparison.
127    ///
128    /// It lives outside normal snapshots and cross-build exact comparisons:
129    /// the same origin variants may be compared under both policies without
130    /// making either result look like a continuation of the other.
131    CrossLanguageComparisonId
132);
133stable_id!(
134    /// Stable identity of one group found by a Rust-to-C++ semantic comparison.
135    CrossLanguageGroupId
136);
137stable_id!(
138    /// Position-free identity of one cross-language group occurrence.
139    CrossLanguageMemberId
140);
141
142/// Version of the policy that defines cross-build-variant comparisons.
143pub const CROSS_VARIANT_POLICY_VERSION: &str = "cross-variant-exact-v1";
144
145/// Version of the explicit Rust-to-C++ semantic comparison policy.
146pub const CROSS_LANGUAGE_POLICY_VERSION: &str = "cross-language-semantic-v1";
147
148/// How content is folded before hashing.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ContentNorm {
151    /// Kind tags plus raw lexeme text: Type-1 identity.
152    Raw,
153    /// Scope-local alpha renaming with the given literal strategy: Type-2
154    /// identity (see [`normalize`]).
155    Normalized(LiteralNorm),
156    /// Scope-local alpha renaming corrected by compiler name resolution.
157    ///
158    /// The compiler answer is optional at each token: when it has no answer,
159    /// the lexical fallback remains in force.  This nevertheless has a
160    /// distinct fingerprint domain because a semantic run must not share
161    /// stored identity with a purely lexical one.
162    ResolvedNormalized(LiteralNorm),
163}
164
165impl ContentNorm {
166    /// Stable label fed into the hash, so raw and normalized digests of the
167    /// same tokens never collide.
168    #[must_use]
169    pub const fn label(self) -> &'static str {
170        match self {
171            Self::Raw => "raw",
172            Self::Normalized(LiteralNorm::Preserve) => "alpha-lit-preserve",
173            Self::Normalized(LiteralNorm::Category) => "alpha-lit-category",
174            Self::Normalized(LiteralNorm::Full) => "alpha-lit-full",
175            Self::ResolvedNormalized(LiteralNorm::Preserve) => "alpha-resolved-lit-preserve",
176            Self::ResolvedNormalized(LiteralNorm::Category) => "alpha-resolved-lit-category",
177            Self::ResolvedNormalized(LiteralNorm::Full) => "alpha-resolved-lit-full",
178        }
179    }
180}
181
182/// Per-file analysis context that participates in content hashing.
183///
184/// The engine identifies files by index only; language and frontend version
185/// are supplied alongside, typically copied from the
186/// [`LexedFile`](crate::frontend::LexedFile) the tokens came from.
187#[derive(Debug, Clone, Copy)]
188pub struct FileContext<'a> {
189    /// Version tag of the frontend that produced the tokens.
190    pub frontend_version: &'a str,
191    /// Language the file was lexed as.
192    pub language: Language,
193}
194
195/// Length-prefixed BLAKE3 hashing with a leading domain tag.
196struct IdHasher {
197    hasher: blake3::Hasher,
198}
199
200impl IdHasher {
201    fn new(domain: &str) -> Self {
202        let mut this = Self {
203            hasher: blake3::Hasher::new(),
204        };
205        this.write_bytes(domain.as_bytes());
206        this
207    }
208
209    fn write_bytes(&mut self, bytes: &[u8]) {
210        let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
211        self.hasher.update(&len.to_le_bytes());
212        self.hasher.update(bytes);
213    }
214
215    fn write_str(&mut self, text: &str) {
216        self.write_bytes(text.as_bytes());
217    }
218
219    fn write_u8(&mut self, value: u8) {
220        self.hasher.update(&[value]);
221    }
222
223    fn write_u32(&mut self, value: u32) {
224        self.hasher.update(&value.to_le_bytes());
225    }
226
227    /// The shared context prefix: schema, normalization, frontend, mode,
228    /// language, build variant — in this fixed order.
229    fn write_context(&mut self, variant: &BuildVariant, file: &FileContext<'_>, norm: ContentNorm) {
230        self.write_str(FP_SCHEMA_VERSION);
231        self.write_str(HASH_ALGORITHM);
232        self.write_str(norm.label());
233        self.write_u32(variant.normalization_version);
234        self.write_str(file.frontend_version);
235        self.write_str(variant.mode.name());
236        self.write_str(file.language.name());
237        self.write_str(&variant.canonical());
238    }
239
240    /// Token content under the chosen normalization. Only kind tags and
241    /// (normalized) text enter the hash; spans never do.
242    fn write_content(
243        &mut self,
244        tokens: &[Token],
245        norm: ContentNorm,
246        resolution: Option<&Resolution>,
247    ) {
248        match norm {
249            ContentNorm::Raw => {
250                for token in tokens {
251                    self.write_u8(token.kind.tag());
252                    self.write_bytes(token.text.as_bytes());
253                }
254            }
255            ContentNorm::Normalized(literals) | ContentNorm::ResolvedNormalized(literals) => {
256                let mut normalized = Vec::new();
257                normalize::normalize_resolved_into(
258                    tokens,
259                    literals,
260                    matches!(norm, ContentNorm::ResolvedNormalized(_))
261                        .then_some(resolution)
262                        .flatten(),
263                    &mut normalized,
264                );
265                for norm_token in normalized {
266                    self.write_u8(norm_token.tag);
267                    match norm_token.atom {
268                        NormAtom::Renamed(n) => {
269                            self.write_u8(1);
270                            self.write_u32(n);
271                        }
272                        NormAtom::Text(text) => {
273                            self.write_u8(2);
274                            self.write_bytes(text.as_bytes());
275                        }
276                        NormAtom::Literal(class) => {
277                            self.write_u8(3);
278                            self.write_u8(class);
279                        }
280                    }
281                }
282            }
283        }
284    }
285
286    fn finish(self) -> [u8; 16] {
287        let digest = self.hasher.finalize();
288        let mut out = [0u8; 16];
289        out.copy_from_slice(&digest.as_bytes()[..16]);
290        out
291    }
292}
293
294/// Fingerprint a whole unit's token stream.
295///
296/// The rename scope of a normalized unit fingerprint is the unit itself, so
297/// the digest depends only on the unit's own content.
298#[must_use]
299pub fn unit_fingerprint(
300    variant: &BuildVariant,
301    file: &FileContext<'_>,
302    tokens: &[Token],
303    norm: ContentNorm,
304) -> UnitFingerprint {
305    let mut hasher = IdHasher::new("unit");
306    hasher.write_context(variant, file, norm);
307    hasher.write_content(tokens, norm, None);
308    UnitFingerprint(hasher.finish())
309}
310
311/// Fingerprint a content slice (candidate fragment or matched run).
312///
313/// `kind` names the syntactic shape the slice was cut from (for example
314/// `body`, `loop`, `member`); slices of different shapes hash apart even with
315/// equal content. The rename scope is the slice itself, so the digest is
316/// independent of the enclosing function.
317#[must_use]
318pub fn fragment_fingerprint(
319    variant: &BuildVariant,
320    file: &FileContext<'_>,
321    kind: &str,
322    tokens: &[Token],
323    norm: ContentNorm,
324) -> FragmentFingerprint {
325    let mut hasher = IdHasher::new("fragment");
326    hasher.write_context(variant, file, norm);
327    hasher.write_str(kind);
328    hasher.write_content(tokens, norm, None);
329    FragmentFingerprint(hasher.finish())
330}
331
332/// Fingerprint a fragment with compiler-derived name-resolution evidence.
333///
334/// Only [`ContentNorm::ResolvedNormalized`] consumes `resolution`; callers
335/// selecting a raw or lexical domain get the same digest as
336/// [`fragment_fingerprint`].  This keeps the compiler boundary in the caller
337/// while making the semantic normalization rule explicit in the ID context.
338#[must_use]
339pub fn resolved_fragment_fingerprint(
340    variant: &BuildVariant,
341    file: &FileContext<'_>,
342    kind: &str,
343    tokens: &[Token],
344    norm: ContentNorm,
345    resolution: Option<&Resolution>,
346) -> FragmentFingerprint {
347    let mut hasher = IdHasher::new("fragment");
348    hasher.write_context(variant, file, norm);
349    hasher.write_str(kind);
350    hasher.write_content(tokens, norm, resolution);
351    FragmentFingerprint(hasher.finish())
352}
353
354/// Fingerprint one normalized semantic graph as a finding fragment.
355///
356/// The graph material is written directly instead of serializing an
357/// implementation-specific helper IR. It includes the SOG schema, the
358/// graph's language and the full `BuildVariant` context, while source
359/// positions remain absent. This makes a normalization-rule revision an
360/// explicit identity boundary rather than an accidental finding rename.
361#[must_use]
362pub fn semantic_fragment_fingerprint(
363    variant: &BuildVariant,
364    graph: &SemanticOperationGraph,
365) -> FragmentFingerprint {
366    let mut hasher = IdHasher::new("fragment-semantic");
367    hasher.write_context(
368        variant,
369        &FileContext {
370            frontend_version: SOG_SCHEMA_VERSION,
371            language: graph.language,
372        },
373        ContentNorm::Raw,
374    );
375    hasher.write_str(&graph.schema_version);
376    hasher.write_u32(u32::try_from(graph.nodes.len()).unwrap_or(u32::MAX));
377    for node in &graph.nodes {
378        hasher.write_str(node.kind.name());
379        match node.attributes.type_tag {
380            Some(tag) => {
381                hasher.write_u8(1);
382                hasher.write_str(tag.name());
383            }
384            None => hasher.write_u8(0),
385        }
386        hasher.write_u32(u32::try_from(node.attributes.api_names.len()).unwrap_or(u32::MAX));
387        for api_name in &node.attributes.api_names {
388            hasher.write_str(api_name);
389        }
390        match &node.attributes.resource_kind {
391            Some(resource_kind) => {
392                hasher.write_u8(1);
393                hasher.write_str(resource_kind);
394            }
395            None => hasher.write_u8(0),
396        }
397        match node.attributes.fallible_kind {
398            Some(kind) => {
399                hasher.write_u8(1);
400                hasher.write_str(kind.name());
401            }
402            None => hasher.write_u8(0),
403        }
404        match node.attributes.direct_propagation {
405            Some(kind) => {
406                hasher.write_u8(1);
407                hasher.write_str(kind.name());
408            }
409            None => hasher.write_u8(0),
410        }
411        match node.attributes.structure_fingerprint {
412            Some(fingerprint) => {
413                hasher.write_u8(1);
414                hasher.write_bytes(&fingerprint);
415            }
416            None => hasher.write_u8(0),
417        }
418    }
419    hasher.write_u32(u32::try_from(graph.edges.len()).unwrap_or(u32::MAX));
420    for edge in &graph.edges {
421        hasher.write_u32(edge.from);
422        hasher.write_u32(edge.to);
423        hasher.write_str(edge.kind.name());
424    }
425    FragmentFingerprint(hasher.finish())
426}
427
428/// Fingerprint source structure attached to a bounded semantic window.
429///
430/// The Structural frontend selects the token slice with source spans, but
431/// only token kind and text enter the digest. Consequently, moving an
432/// unchanged window does not change the value. The signature is deliberately
433/// separate from normal clone content: it is conservative same-variant
434/// evidence used to distinguish the expressions supplied to registered APIs.
435#[must_use]
436pub fn semantic_structure_fingerprint(
437    variant: &BuildVariant,
438    file: &FileContext<'_>,
439    tokens: &[Token],
440) -> [u8; 16] {
441    let mut hasher = IdHasher::new("semantic-source-structure-v1");
442    hasher.write_context(variant, file, ContentNorm::Raw);
443    hasher.write_content(tokens, ContentNorm::Raw, None);
444    hasher.finish()
445}
446
447/// Identify one semantic fragment occurrence inside its stable host unit.
448///
449/// Semantic content intentionally excludes source position, so identical
450/// windows in distinct hosts need this separate identity before they can form
451/// a group without collapsing. The rank is assigned once per host by the scan
452/// after deterministic window extraction; it is not a source offset.
453#[must_use]
454pub fn semantic_occurrence_fingerprint(
455    content: FragmentFingerprint,
456    host: &UnitFingerprint,
457    occurrence_rank: u32,
458) -> FragmentFingerprint {
459    let mut hasher = IdHasher::new("semantic-occurrence-v1");
460    hasher.write_str(FP_SCHEMA_VERSION);
461    hasher.write_bytes(content.as_bytes());
462    hasher.write_bytes(host.as_bytes());
463    hasher.write_u32(occurrence_rank);
464    FragmentFingerprint(hasher.finish())
465}
466
467/// Fingerprint a clone group from its members' content fingerprints.
468///
469/// Member fingerprints are sorted and deduplicated first, so the digest is
470/// independent of member order and of how many occurrences share identical
471/// content: adding another copy of known content leaves the group fingerprint
472/// unchanged, while genuinely new member content changes it.
473#[must_use]
474pub fn clone_group_fingerprint(
475    variant: &BuildVariant,
476    clone_type: CloneClass,
477    members: &[FragmentFingerprint],
478) -> CloneGroupFingerprint {
479    let mut distinct: Vec<[u8; 16]> = members.iter().map(|m| m.0).collect();
480    distinct.sort_unstable();
481    distinct.dedup();
482
483    let mut hasher = IdHasher::new("group");
484    hasher.write_str(FP_SCHEMA_VERSION);
485    hasher.write_str(HASH_ALGORITHM);
486    hasher.write_str(variant.mode.name());
487    hasher.write_str(&variant.canonical());
488    hasher.write_str(clone_type.name());
489    hasher.write_u32(u32::try_from(distinct.len()).unwrap_or(u32::MAX));
490    for bytes in &distinct {
491        hasher.write_bytes(bytes);
492    }
493    CloneGroupFingerprint(hasher.finish())
494}
495
496/// Start a clone-group history from the fingerprint that first identified it.
497///
498/// The separate domain ensures a lineage identifier cannot be mistaken for a
499/// current finding identifier even when both are rendered as hexadecimal.
500#[must_use]
501pub fn group_lineage_id(group: &CloneGroupFingerprint) -> GroupLineageId {
502    let mut hasher = IdHasher::new("group-lineage");
503    hasher.write_str(FP_SCHEMA_VERSION);
504    hasher.write_str(HASH_ALGORITHM);
505    hasher.write_bytes(group.as_bytes());
506    GroupLineageId(hasher.finish())
507}
508
509/// Fingerprint a restricted-semantic group after a registered rule matched.
510///
511/// This keeps rule identity and revision separate from the normalized graph
512/// fragments. A future change to a rule cannot silently claim continuity with
513/// a finding justified by different semantics.
514#[must_use]
515pub fn semantic_clone_group_fingerprint(
516    variant: &BuildVariant,
517    rule_id: &str,
518    rule_version: u32,
519    members: &[FragmentFingerprint],
520) -> CloneGroupFingerprint {
521    let mut occurrences: Vec<[u8; 16]> = members.iter().map(|member| member.0).collect();
522    occurrences.sort_unstable();
523
524    let mut hasher = IdHasher::new("group-semantic");
525    hasher.write_str(FP_SCHEMA_VERSION);
526    hasher.write_str(HASH_ALGORITHM);
527    hasher.write_str(variant.mode.name());
528    hasher.write_str(&variant.canonical());
529    hasher.write_str(CloneClass::RestrictedSemantic.name());
530    hasher.write_str(SOG_SCHEMA_VERSION);
531    hasher.write_str(rule_id);
532    hasher.write_u32(rule_version);
533    hasher.write_u32(u32::try_from(occurrences.len()).unwrap_or(u32::MAX));
534    for bytes in &occurrences {
535        hasher.write_bytes(bytes);
536    }
537    CloneGroupFingerprint(hasher.finish())
538}
539
540/// Fingerprint a Structural (Type-3) clone group, anchored on its canonical
541/// instance.
542///
543/// A Type-3 group's members are similar but not identical, so — unlike a
544/// Type-1/2 group, whose members share one content fingerprint — there is no
545/// single content to hash. The group is instead identified by its canonical
546/// instance (the medoid, see [`crate::grouping`]) *and* the order-independent,
547/// deduplicated set of its members' own content fingerprints. Anchoring on the
548/// medoid keeps the identity tied to a concrete instance; folding in the whole
549/// member set means adding genuinely new member content changes the
550/// fingerprint, while reordering members or repeating identical content does
551/// not.
552///
553/// The `canonical` fingerprint should also appear in `members`; it is hashed a
554/// second time, in a distinct anchor position, so two groups with the same
555/// member set but different medoids hash apart.
556#[must_use]
557pub fn structural_clone_group_fingerprint(
558    variant: &BuildVariant,
559    class: CloneClass,
560    canonical: &FragmentFingerprint,
561    members: &[FragmentFingerprint],
562) -> CloneGroupFingerprint {
563    let mut distinct: Vec<[u8; 16]> = members.iter().map(|m| m.0).collect();
564    distinct.sort_unstable();
565    distinct.dedup();
566
567    let mut hasher = IdHasher::new("group-structural");
568    hasher.write_str(FP_SCHEMA_VERSION);
569    hasher.write_str(HASH_ALGORITHM);
570    hasher.write_str(variant.mode.name());
571    hasher.write_str(&variant.canonical());
572    hasher.write_str(class.name());
573    // Anchor on the canonical instance, then the order-independent member set.
574    hasher.write_bytes(&canonical.0);
575    hasher.write_u32(u32::try_from(distinct.len()).unwrap_or(u32::MAX));
576    for bytes in &distinct {
577        hasher.write_bytes(bytes);
578    }
579    CloneGroupFingerprint(hasher.finish())
580}
581
582/// Identify one occurrence of a group's content.
583///
584/// Content-identical occurrences share their content fingerprint, so a
585/// finding is discriminated by its host unit's (raw) fingerprint plus its
586/// occurrence rank *within that host* — content-relative inputs, not source
587/// positions. An occurrence outside any unit uses an absent-host marker; two
588/// such occurrences are then told apart by rank alone, which is the weakest
589/// (but position-free) discriminator available at this layer.
590#[must_use]
591pub fn finding_id(
592    group: &CloneGroupFingerprint,
593    host: Option<&UnitFingerprint>,
594    rank_in_host: u32,
595) -> FindingId {
596    let mut hasher = IdHasher::new("finding");
597    hasher.write_str(FP_SCHEMA_VERSION);
598    hasher.write_bytes(&group.0);
599    match host {
600        Some(unit) => {
601            hasher.write_u8(1);
602            hasher.write_bytes(&unit.0);
603        }
604        None => hasher.write_u8(0),
605    }
606    hasher.write_u32(rank_in_host);
607    FindingId(hasher.finish())
608}
609
610/// Identify an opt-in comparison over the sorted set of origin variants.
611///
612/// The caller supplies variant fingerprints rather than a synthesized
613/// [`BuildVariant`]: comparison members remain attributed to the program that
614/// produced them.
615#[must_use]
616pub fn cross_variant_comparison_id(origins: &[String]) -> CrossVariantComparisonId {
617    let mut origins = origins.to_vec();
618    origins.sort_unstable();
619    origins.dedup();
620    let mut hasher = IdHasher::new("cross-variant-comparison");
621    hasher.write_str(FP_SCHEMA_VERSION);
622    hasher.write_str(CROSS_VARIANT_POLICY_VERSION);
623    hasher.write_u32(u32::try_from(origins.len()).unwrap_or(u32::MAX));
624    for origin in origins {
625        hasher.write_str(&origin);
626    }
627    CrossVariantComparisonId(hasher.finish())
628}
629
630/// Identify an explicit Rust-to-C++ semantic comparison over origin variants.
631///
632/// The origin list is canonicalised before hashing, while every compared graph
633/// retains its own full `BuildVariant` fingerprint. This identifier is only the
634/// comparison domain used to prevent unrelated requests from joining.
635#[must_use]
636pub fn cross_language_comparison_id(origins: &[String]) -> CrossLanguageComparisonId {
637    let mut origins = origins.to_vec();
638    origins.sort_unstable();
639    origins.dedup();
640    let mut hasher = IdHasher::new("cross-language-comparison");
641    hasher.write_str(FP_SCHEMA_VERSION);
642    hasher.write_str(CROSS_LANGUAGE_POLICY_VERSION);
643    hasher.write_u32(u32::try_from(origins.len()).unwrap_or(u32::MAX));
644    for origin in origins {
645        hasher.write_str(&origin);
646    }
647    CrossLanguageComparisonId(hasher.finish())
648}
649
650/// Identify a verified group from an explicit Rust-to-C++ semantic comparison.
651///
652/// Member fingerprints already include each graph's language, schema and full
653/// `BuildVariant` context. The comparison identity, rule revision and sorted
654/// member set make this separate from normal semantic and exact-comparison
655/// group identities.
656#[must_use]
657pub fn cross_language_group_id(
658    comparison: &CrossLanguageComparisonId,
659    rule_id: &str,
660    rule_version: u32,
661    members: &[FragmentFingerprint],
662) -> CrossLanguageGroupId {
663    let mut members = members.to_vec();
664    members.sort_unstable();
665    let mut hasher = IdHasher::new("cross-language-group");
666    hasher.write_str(FP_SCHEMA_VERSION);
667    hasher.write_str(CROSS_LANGUAGE_POLICY_VERSION);
668    hasher.write_bytes(comparison.as_bytes());
669    hasher.write_str(rule_id);
670    hasher.write_u32(rule_version);
671    hasher.write_u32(u32::try_from(members.len()).unwrap_or(u32::MAX));
672    for member in members {
673        hasher.write_bytes(member.as_bytes());
674    }
675    CrossLanguageGroupId(hasher.finish())
676}
677
678/// Identify an exact group produced by a cross-build-variant comparison.
679///
680/// `content` is a position-free digest of the matched token stream. The
681/// comparison id carries the complete origin-variant set, so adding another
682/// compared program cannot silently continue an older comparison's history.
683#[must_use]
684pub fn cross_variant_group_id(
685    comparison: &CrossVariantComparisonId,
686    class: CloneClass,
687    language: Language,
688    content: &[u8; 16],
689) -> CrossVariantGroupId {
690    let mut hasher = IdHasher::new("cross-variant-group");
691    hasher.write_str(FP_SCHEMA_VERSION);
692    hasher.write_str(CROSS_VARIANT_POLICY_VERSION);
693    hasher.write_bytes(comparison.as_bytes());
694    hasher.write_str(class.name());
695    hasher.write_str(language.name());
696    hasher.write_bytes(content);
697    CrossVariantGroupId(hasher.finish())
698}
699
700/// Identify one occurrence inside a cross-build-variant group.
701///
702/// Exact duplicates inside one origin are distinguished by a deterministic
703/// occurrence rank, never by their path or line anchor.
704#[must_use]
705pub fn cross_variant_member_id(
706    group: &CrossVariantGroupId,
707    origin_variant: &str,
708    language: Language,
709    occurrence_rank: u32,
710) -> CrossVariantMemberId {
711    let mut hasher = IdHasher::new("cross-variant-member");
712    hasher.write_str(FP_SCHEMA_VERSION);
713    hasher.write_str(CROSS_VARIANT_POLICY_VERSION);
714    hasher.write_bytes(group.as_bytes());
715    hasher.write_str(origin_variant);
716    hasher.write_str(language.name());
717    hasher.write_u32(occurrence_rank);
718    CrossVariantMemberId(hasher.finish())
719}
720
721/// Identify one occurrence inside a cross-language semantic group.
722#[must_use]
723pub fn cross_language_member_id(
724    group: &CrossLanguageGroupId,
725    origin_variant: &str,
726    occurrence: &FragmentFingerprint,
727) -> CrossLanguageMemberId {
728    let mut hasher = IdHasher::new("cross-language-member");
729    hasher.write_str(FP_SCHEMA_VERSION);
730    hasher.write_str(CROSS_LANGUAGE_POLICY_VERSION);
731    hasher.write_bytes(group.as_bytes());
732    hasher.write_str(origin_variant);
733    hasher.write_bytes(occurrence.as_bytes());
734    CrossLanguageMemberId(hasher.finish())
735}
736
737/// Stable identifiers of one group member, parallel to
738/// [`CloneGroup::members`](crate::engine::CloneGroup::members).
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub struct MemberIds {
741    /// Content fingerprint of the matched slice.
742    pub content: FragmentFingerprint,
743    /// This occurrence's finding identifier.
744    pub finding: FindingId,
745}
746
747/// Stable identifiers of one clone group.
748#[derive(Debug, Clone, PartialEq, Eq)]
749pub struct GroupIds {
750    /// The group's fingerprint.
751    pub fingerprint: CloneGroupFingerprint,
752    /// Per-member identifiers, in the group's member order.
753    pub members: Vec<MemberIds>,
754}
755
756/// Compute stable identifiers for every group of an engine report.
757///
758/// `contexts` runs parallel to `files`. Type-1 groups hash their members
759/// raw; Type-2 groups hash them under scope-local normalization with
760/// `literals` (the strategy the detection ran with), so the fingerprint
761/// captures exactly the identity that made the members a group. The engine
762/// this reads reports no gapped clones, so only those two classes occur;
763/// gapped groups are identified by
764/// [`structural_clone_group_fingerprint`] instead, which anchors on a
765/// canonical instance rather than on one shared content.
766#[must_use]
767pub fn report_ids(
768    files: &[InputFile<'_>],
769    contexts: &[FileContext<'_>],
770    variant: &BuildVariant,
771    report: &EngineReport,
772    literals: LiteralNorm,
773) -> Vec<GroupIds> {
774    report
775        .groups
776        .iter()
777        .map(|group| {
778            let norm = match group.clone_type {
779                CloneClass::Type1 => ContentNorm::Raw,
780                CloneClass::Type2 | CloneClass::Type3 | CloneClass::RestrictedSemantic => {
781                    ContentNorm::Normalized(literals)
782                }
783            };
784            let member_fps: Vec<FragmentFingerprint> = group
785                .members
786                .iter()
787                .map(|member| {
788                    let file = &files[member.file];
789                    let start = member.token_start.min(file.tokens.len());
790                    let end = member.token_end.min(file.tokens.len()).max(start);
791                    let tokens = &file.tokens[start..end];
792                    fragment_fingerprint(variant, &contexts[member.file], "member", tokens, norm)
793                })
794                .collect();
795            let fingerprint = clone_group_fingerprint(variant, group.clone_type, &member_fps);
796
797            // Rank occurrences within their host unit, in member order (which
798            // is deterministic); the rank is content-relative, not positional.
799            let hosts: Vec<Option<UnitFingerprint>> = group
800                .members
801                .iter()
802                .map(|member| {
803                    member.unit.map(|unit_idx| {
804                        let unit = &files[member.file].units[unit_idx];
805                        let file = &files[member.file];
806                        let start = unit.token_start.min(file.tokens.len());
807                        let end = unit.token_end.min(file.tokens.len()).max(start);
808                        let tokens = &file.tokens[start..end];
809                        unit_fingerprint(variant, &contexts[member.file], tokens, ContentNorm::Raw)
810                    })
811                })
812                .collect();
813            let members = member_fps
814                .iter()
815                .zip(hosts.iter())
816                .enumerate()
817                .map(|(i, (content, host))| {
818                    let rank = hosts[..i].iter().filter(|h| *h == host).count();
819                    MemberIds {
820                        content: *content,
821                        finding: finding_id(
822                            &fingerprint,
823                            host.as_ref(),
824                            u32::try_from(rank).unwrap_or(u32::MAX),
825                        ),
826                    }
827                })
828                .collect();
829            GroupIds {
830                fingerprint,
831                members,
832            }
833        })
834        .collect()
835}
836
837#[cfg(test)]
838#[allow(clippy::expect_used, clippy::unwrap_used)]
839mod tests;