Skip to main content

codehelion_core/structural/
reporting.rs

1use super::{
2    BTreeMap, BTreeSet, BodyMateriality, Boilerplate, BuildVariant, CloneGroupFingerprint,
3    FileFeatures, FragmentFingerprint, GroupDetail, Lexeme, SourceTokenSpan, StructuralConfig,
4    SyntaxIrFile, Token, TokenKind, Unit, UnitEvidence, features, grouping, stable_id,
5    substitution, test_code, verify, view,
6};
7
8/// Compute one group's reporting detail: its stable clone fingerprint (anchored
9/// on the medoid's content, folding the member set) and the medoid-to-member
10/// similarity breakdowns.
11pub(super) fn group_detail(
12    group: &grouping::StructuralGroup,
13    units: &[Unit],
14    files: &[SyntaxIrFile],
15    feature_files: &[FileFeatures],
16    evidence: &UnitEvidence,
17    variant: &BuildVariant,
18    config: &StructuralConfig,
19) -> GroupDetail {
20    let medoid_view = view(group.canonical, units, files, feature_files, evidence);
21    let member_breakdowns = group
22        .members
23        .iter()
24        .map(|&member| {
25            verify::verify(
26                &medoid_view,
27                &view(member, units, files, feature_files, evidence),
28                &config.verify,
29            )
30            .breakdown
31        })
32        .collect();
33    let cohesion_breakdown = group
34        .members
35        .iter()
36        .enumerate()
37        .flat_map(|(left, &a)| group.members[left + 1..].iter().map(move |&b| (a, b)))
38        .map(|(a, b)| {
39            verify::verify(
40                &view(a, units, files, feature_files, evidence),
41                &view(b, units, files, feature_files, evidence),
42                &config.verify,
43            )
44            .breakdown
45        })
46        .min_by(|left, right| left.composite.total_cmp(&right.composite))
47        // Grouping emits only multi-member groups. Preserve a total report
48        // function for a malformed externally-constructed group as well:
49        // its medoid's self-comparison is the only available evidence.
50        .unwrap_or_else(|| verify::verify(&medoid_view, &medoid_view, &config.verify).breakdown);
51
52    let fingerprint = group_fingerprint(group, units, variant);
53
54    GroupDetail {
55        fingerprint,
56        member_breakdowns,
57        cohesion_breakdown,
58        identifier_jaccard: group_identifier_jaccard(group, units, files),
59        body_materiality: group_body_materiality(group, units, feature_files),
60        boilerplate: dominant_boilerplate(group, units),
61        test_code: group.members.iter().all(|&member| units[member].test_code),
62        test_code_evidence: test_code::aggregate_evidence(
63            group
64                .members
65                .iter()
66                .map(|&member| units[member].test_code_evidence),
67        ),
68        width_family: written_once_per_width(group, units, files),
69    }
70}
71
72/// Compose a structural group id from the content domain its class promises.
73///
74/// Type-1 identity is exact source content. Type-2 and Type-3 identity is
75/// identifier-normalized content so a consistent rename does not turn an
76/// otherwise unchanged clone relation into a new baseline finding.
77pub(super) fn group_fingerprint(
78    group: &grouping::StructuralGroup,
79    units: &[Unit],
80    variant: &BuildVariant,
81) -> CloneGroupFingerprint {
82    let member_contents: Vec<FragmentFingerprint> = group
83        .members
84        .iter()
85        .map(|&member| units[member].group_content(group.clone_type))
86        .collect();
87    stable_id::structural_clone_group_fingerprint(
88        variant,
89        group.clone_type,
90        &units[group.canonical].group_content(group.clone_type),
91        &member_contents,
92    )
93}
94
95/// Material work that exists in every member rather than just the medoid.
96pub(super) fn group_body_materiality(
97    group: &grouping::StructuralGroup,
98    units: &[Unit],
99    feature_files: &[FileFeatures],
100) -> BodyMateriality {
101    let members: Vec<&features::UnitFeatures> = group
102        .members
103        .iter()
104        .map(|&member| {
105            let unit = &units[member];
106            &feature_files[unit.file].units[unit.local]
107        })
108        .collect();
109    BodyMateriality {
110        has_loop: members
111            .iter()
112            .all(|features| features.cfg.max_loop_depth > 0),
113        has_dynamic_allocation: members
114            .iter()
115            .all(|features| features.api.names.iter().any(is_allocation_api)),
116        call_count: members
117            .iter()
118            .map(|features| u64::try_from(features.api.names.len()).unwrap_or(u64::MAX))
119            .min()
120            .unwrap_or(0),
121    }
122}
123
124/// Allocation APIs recognised without a compiler backend.
125///
126/// The lexical frontend intentionally recognises only explicit, portable
127/// allocator names. An unfamiliar wrapper is absence of evidence, not a
128/// guess that the call allocates.
129pub(super) fn is_allocation_api(name: &Lexeme) -> bool {
130    matches!(
131        name.as_str(),
132        "aligned_alloc"
133            | "calloc"
134            | "make_shared"
135            | "make_unique"
136            | "malloc"
137            | "realloc"
138            | "reserve"
139            | "reserve_exact"
140            | "try_reserve"
141            | "try_reserve_exact"
142            | "with_capacity"
143            | "with_capacity_and_hasher"
144    )
145}
146
147/// The weakest raw identifier-set agreement between a canonical span and its
148/// corresponding spans.
149///
150/// This is reporting and triage evidence only. In particular, a duplicated
151/// run may have exact normalized content while this value is low because its
152/// names differ; the value is a proxy for whether a shared refactoring target
153/// may exist, not a similarity measurement and never an input to detection or
154/// grouping.
155#[must_use]
156pub fn span_identifier_jaccard(
157    files: &[SyntaxIrFile],
158    canonical: SourceTokenSpan,
159    corresponding: impl IntoIterator<Item = SourceTokenSpan>,
160) -> f64 {
161    let canonical = identifier_set(files, canonical);
162    corresponding
163        .into_iter()
164        .map(|span| set_jaccard(&canonical, &identifier_set(files, span)))
165        .min_by(f64::total_cmp)
166        .unwrap_or(1.0)
167}
168
169/// The weakest identifier-set agreement between a canonical unit and its
170/// group members.
171fn group_identifier_jaccard(
172    group: &grouping::StructuralGroup,
173    units: &[Unit],
174    files: &[SyntaxIrFile],
175) -> f64 {
176    span_identifier_jaccard(
177        files,
178        unit_token_span(&units[group.canonical]),
179        group
180            .members
181            .iter()
182            .filter(|&&member| member != group.canonical)
183            .map(|&member| unit_token_span(&units[member])),
184    )
185}
186
187const fn unit_token_span(unit: &Unit) -> SourceTokenSpan {
188    SourceTokenSpan::new(unit.file, unit.tokens.0, unit.tokens.1)
189}
190
191fn identifier_set(files: &[SyntaxIrFile], span: SourceTokenSpan) -> BTreeSet<&str> {
192    let tokens = files
193        .get(span.file)
194        .and_then(|file| file.tokens.get(span.token_start..span.token_end))
195        .unwrap_or(&[]);
196    tokens
197        .iter()
198        .filter(|token| matches!(token.kind, TokenKind::Identifier))
199        .map(|token| token.text.as_str())
200        .collect()
201}
202
203#[allow(clippy::cast_precision_loss)]
204pub(super) fn set_jaccard(left: &BTreeSet<&str>, right: &BTreeSet<&str>) -> f64 {
205    let union = left.union(right).count();
206    if union == 0 {
207        return 1.0;
208    }
209    // One set entry requires one input token; discovery's file-size ceiling
210    // bounds this far below the integer range where a report ratio loses a
211    // meaningful displayed digit.
212    left.intersection(right).count() as f64 / union as f64
213}
214
215/// Whether every member differs from the medoid by one integer width and
216/// nothing else.
217///
218/// Asked of each member against the medoid rather than of one pair, because the
219/// answer decides what the whole group is. A family written for four widths
220/// gives four different swaps against the same medoid and each is one, which is
221/// the point; a group where one member is a real copy and another a width
222/// variant is not a family and must not read as one.
223///
224/// A group whose members are the same text answers no. Nothing was substituted,
225/// so nothing says the two were written per width — that is a plain copy.
226pub(super) fn written_once_per_width(
227    group: &grouping::StructuralGroup,
228    units: &[Unit],
229    files: &[SyntaxIrFile],
230) -> bool {
231    written_once_per_width_members(group.canonical, &group.members, units, files)
232}
233
234/// The pair counterpart of [`written_once_per_width`].
235pub(super) fn written_once_per_width_members(
236    canonical: usize,
237    members: &[usize],
238    units: &[Unit],
239    files: &[SyntaxIrFile],
240) -> bool {
241    let medoid = unit_tokens(&units[canonical], files);
242    let mut compared = 0usize;
243    for &member in members {
244        if member == canonical {
245            continue;
246        }
247        compared += 1;
248        let alike = substitution::witness(medoid, unit_tokens(&units[member], files))
249            .is_some_and(|witness| witness.written_once_per_width());
250        if !alike {
251            return false;
252        }
253    }
254    compared > 0
255}
256
257/// The tokens one unit covers, in its file's stream.
258fn unit_tokens<'a>(unit: &Unit, files: &'a [SyntaxIrFile]) -> &'a [Token] {
259    &files[unit.file].tokens[unit.tokens.0..unit.tokens.1]
260}
261
262/// The category that covers at least four fifths of one cohesive group.
263///
264/// Clone grouping permits structurally similar bodies to differ in a small
265/// number of details. Requiring unanimity therefore let a single exceptional
266/// body erase the useful classification of a large predicate family. The
267/// threshold is intentionally strict: a two-member pair still needs both
268/// members to agree, while a high-instance group can retain a few explicitly
269/// visible exceptions.
270pub(super) fn dominant_boilerplate(
271    group: &grouping::StructuralGroup,
272    units: &[Unit],
273) -> Option<Boilerplate> {
274    dominant_boilerplate_members(&group.members, units)
275}
276
277/// The pair counterpart of [`dominant_boilerplate`].
278pub(super) fn dominant_boilerplate_members(
279    members: &[usize],
280    units: &[Unit],
281) -> Option<Boilerplate> {
282    let mut counts = BTreeMap::new();
283    for &member in members {
284        if let Some(category) = units[member].boilerplate {
285            *counts.entry(category).or_insert(0usize) += 1;
286        }
287    }
288    let (category, count) = counts
289        .into_iter()
290        .max_by_key(|(category, count)| (*count, *category))?;
291    (count.saturating_mul(5) >= members.len().saturating_mul(4)).then_some(category)
292}