kimun 0.34.1

Code metrics tool — health score, complexity, duplication, hotspots, ownership
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! Structural radius: the source files that use what a change touches, and
//! which of them no test exercises.
//!
//! This is the answer to "the tests of the module I changed pass; who else
//! uses it?". It reads the dependency graph backwards from the changed
//! files. A dependent that no test file refers to is unprotected: the change
//! can break it without any test noticing.

use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};

use serde::Serialize;

use crate::deps::graph::{FileGraph, Related, Unnarrowed};
use crate::git::{ChangeKind, FileDiffStat};

use super::protection::{Protection, Tests};
use super::reading::{self, graphed_language};
use super::role::{self, Role, roles_of};
use super::routes;

/// Languages whose graph reflects what uses what. For the others `deps`
/// reads declarations that do not amount to usage, and a radius drawn on
/// them would be made up.
const RELIABLE: &[&str] = &[
    "Elixir",
    "Elixir Script",
    "JavaScript",
    "TypeScript",
    "JSX",
    "TSX",
    "Kaikai",
    "Python",
    "Rust",
];

/// Whether the graph of the language of `path` can be trusted for a radius.
pub fn is_reliable(path: &Path) -> bool {
    graphed_language(path).is_some_and(|language| RELIABLE.contains(&language))
}

/// How a file that uses a changed one stands to the change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Exposure {
    /// It calls a function that changed, or the change could not be
    /// narrowed to functions and any use counts.
    Calls,
    /// It refers to the changed module without calling it: a struct, an
    /// `import`, a `use`. Whether the change concerns it is not known.
    Refers,
    /// It only calls functions the change leaves alone.
    Elsewhere,
}

/// A source file that uses a changed one directly.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Dependent {
    pub file: PathBuf,
    pub exposure: Exposure,
    /// The calls that expose it: to the functions that changed when those
    /// are known, to the changed files otherwise.
    pub calls: Vec<String>,
    /// How tests protect it.
    pub protection: Protection,
    /// Test files that refer to it, or that mirror its path.
    pub tests: usize,
    /// How many of those tests are part of the change.
    pub tests_in_diff: usize,
    /// Whether it is run rather than used: a command-line task, a script.
    pub entry_point: bool,
    /// Whether a source file uses it.
    #[serde(skip)]
    pub used: bool,
}

/// What a change comes to in one changed file: the public functions it
/// affects, or why they cannot be told and every use of the file counts.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Narrowing {
    pub file: PathBuf,
    pub functions: Option<Vec<String>>,
    /// Why not, when `functions` is `None`.
    pub reason: Option<String>,
}

/// Why the change to `source` cannot be narrowed, in words.
fn reason(stat: &FileDiffStat, why: Unnarrowed) -> String {
    match (stat.kind, why) {
        (ChangeKind::Added, _) => "new file".to_string(),
        (_, Unnarrowed::Language) => "its language does not tell functions".to_string(),
        (_, Unnarrowed::NoLines) => "no changed line is known".to_string(),
        (_, Unnarrowed::Outside(line)) => format!("line {line} is outside every function"),
    }
}

/// A changed file, and where the change touches it.
pub type Changed<'a> = &'a FileDiffStat;

/// The structural radius of a change.
#[derive(Debug, Default, PartialEq)]
pub struct Structural {
    /// Changed source files the radius starts from.
    pub origins: Vec<PathBuf>,
    /// Public functions the change affects, over the origins that tell
    /// them. `None` when no origin could be narrowed to functions.
    pub functions: Option<Vec<String>>,
    /// What the change comes to in each origin.
    pub narrowing: Vec<Narrowing>,
    /// Source files measured: the ones a file can be reached among.
    pub source_files: usize,
    /// Files that use an origin directly, most exposed first.
    pub direct: Vec<Dependent>,
    /// Files reached at each distance, starting at 1 with the files that
    /// call what changed. What only passes through a file that calls
    /// elsewhere, or that merely refers to the module, is left out.
    pub by_distance: Vec<usize>,
    /// The files behind `by_distance`, distance by distance.
    pub reached_files: Vec<Vec<PathBuf>>,
    /// Files reached when every use of a changed file counts, whatever the
    /// function: what the radius cannot exceed.
    pub upper_bound: usize,
    /// Changed files in languages whose graph is not reliable, by language.
    pub unavailable: Vec<(String, usize)>,
    /// Elixir module references left out for naming several files.
    pub ambiguous: usize,
}

impl Structural {
    /// Source files reached, at any distance.
    pub fn reached(&self) -> usize {
        self.by_distance.iter().sum()
    }

    /// Direct dependents the change concerns: all but the ones that only
    /// call functions it leaves alone.
    pub fn exposed(&self) -> impl Iterator<Item = &Dependent> {
        self.direct
            .iter()
            .filter(|d| d.exposure != Exposure::Elsewhere)
    }

    /// Dependents that call what changed and that no test reaches.
    fn untested(&self) -> impl Iterator<Item = &Dependent> {
        self.direct
            .iter()
            .filter(|d| d.exposure == Exposure::Calls && d.protection == Protection::None)
    }

    /// Dependents that call what changed and that no test reaches, not even
    /// through the files that use them. Entry points are told apart:
    /// nothing uses them, so no test of something else could reach them.
    pub fn unprotected(&self) -> impl Iterator<Item = &Dependent> {
        self.untested().filter(|d| !d.entry_point)
    }

    /// Entry points that call what changed and have no test.
    pub fn untested_entry_points(&self) -> impl Iterator<Item = &Dependent> {
        self.untested().filter(|d| d.entry_point)
    }

    /// Mark the entry points among the dependents: those `declared` so,
    /// and those at a `conventional` place that no source file uses.
    pub fn mark_entry_points(
        &mut self,
        conventional: impl Fn(&Path) -> bool,
        declared: impl Fn(&Path) -> bool,
    ) {
        for d in &mut self.direct {
            d.entry_point = declared(&d.file) || (!d.used && conventional(&d.file));
        }
    }
}

/// Changed files in languages that have a graph, but not a reliable one.
fn unavailable(changed: &[&Path]) -> Vec<(String, usize)> {
    let mut by_language: BTreeMap<&str, usize> = BTreeMap::new();
    for path in changed {
        if let Some(language) = graphed_language(path).filter(|l| !RELIABLE.contains(l)) {
            *by_language.entry(language).or_default() += 1;
        }
    }
    by_language
        .into_iter()
        .map(|(language, files)| (language.to_string(), files))
        .collect()
}

/// The graph read backwards: for each file, the sources and the tests that
/// use it.
struct Reverse {
    sources: Vec<Vec<usize>>,
    tests: Tests,
}

impl Reverse {
    /// `referring` holds, for each file, the tests known to reach it by
    /// other means than naming it.
    fn of(graph: &FileGraph, roles: &[Role], mut referring: Vec<Vec<usize>>) -> Self {
        let mut sources = vec![Vec::new(); graph.files.len()];
        for (file, uses) in graph.uses.iter().enumerate() {
            let into = match roles[file] {
                Role::Source => &mut sources,
                Role::Test => &mut referring,
                Role::Other => continue,
            };
            for used in uses {
                into[used.to].push(file);
            }
        }
        role::own_tests(graph, roles, &mut referring);
        let tests = Tests::of(
            &graph.files,
            referring,
            |file| roles[file] == Role::Test,
            |file| roles[file] == Role::Source,
        );
        Self { sources, tests }
    }

    /// The sources that use `frontier`, then those that use them, and so
    /// on, each group one step further. Files in `seen` are not reached again.
    fn levels_beyond(&self, frontier: &[usize], mut seen: HashSet<usize>) -> Vec<Vec<usize>> {
        let mut levels = Vec::new();
        let mut frontier = frontier.to_vec();
        loop {
            let mut next: Vec<usize> = frontier
                .iter()
                .flat_map(|&file| &self.sources[file])
                .copied()
                .filter(|file| seen.insert(*file))
                .collect();
            if next.is_empty() {
                return levels;
            }
            next.sort_unstable();
            levels.push(next.clone());
            frontier = next;
        }
    }

    /// The sources reached from `origins`, grouped by distance from 1.
    fn levels(&self, origins: &[usize]) -> Vec<Vec<usize>> {
        self.levels_beyond(origins, origins.iter().copied().collect())
    }

    /// The reach that starts at `calling`, the files that call what changed,
    /// at distance 1, without going back to the `origins`.
    fn levels_through(&self, origins: &[usize], calling: &[usize]) -> Vec<Vec<usize>> {
        if calling.is_empty() {
            return Vec::new();
        }
        let seen = origins.iter().chain(calling).copied().collect();
        let mut levels = vec![calling.to_vec()];
        levels.extend(self.levels_beyond(calling, seen));
        levels
    }
}

/// `Tip.apply` for `Booking.Tip.apply`: the module's own name is enough
/// next to the file it is in.
fn short_call(call: &str) -> String {
    let mut parts: Vec<&str> = call.rsplitn(3, '.').collect();
    parts.truncate(2);
    parts.reverse();
    parts.join(".")
}

/// The function a call names: `apply` in `Booking.Tip.apply`.
fn function_of(call: &str) -> &str {
    call.rsplit('.').next().unwrap_or(call)
}

/// How one file's use of an origin stands to what changed in that origin,
/// and the calls that tell.
fn exposure_of(calls: &[String], changed: Option<&BTreeSet<String>>) -> (Exposure, Vec<String>) {
    let Some(changed) = changed else {
        return (Exposure::Calls, calls.to_vec());
    };
    let hits: Vec<String> = calls
        .iter()
        .filter(|call| changed.contains(function_of(call)))
        .cloned()
        .collect();
    match (hits.is_empty(), calls.is_empty()) {
        (false, _) => (Exposure::Calls, hits),
        (true, true) => (Exposure::Refers, Vec::new()),
        (true, false) => (Exposure::Elsewhere, Vec::new()),
    }
}

/// Measure the structural radius of `changed` over `sources`.
pub fn compute(
    sources: Vec<(PathBuf, String)>,
    changed: &[Changed<'_>],
    related: Related,
) -> Structural {
    let (sources, graph) = reading::read(sources, related);
    let roles = roles_of(&graph);
    let requesting = routes::requesting(&sources, &graph, |f| roles[f] == Role::Test, related);
    let reverse = Reverse::of(&graph, &roles, requesting);

    let stat_of: HashMap<&Path, Changed> = changed.iter().map(|c| (c.path.as_path(), *c)).collect();
    let in_diff = |file: usize| stat_of.contains_key(graph.files[file].as_path());
    // Each origin with the functions the change affects in it, if known.
    let origins: BTreeMap<usize, Result<BTreeSet<String>, Unnarrowed>> = (0..graph.files.len())
        .filter(|&f| roles[f] == Role::Source && in_diff(f))
        .map(|f| {
            let lines = stat_of[graph.files[f].as_path()].lines_in(&sources[f].text);
            (f, sources[f].changed_functions(lines))
        })
        .collect();
    let starts: Vec<usize> = origins.keys().copied().collect();
    let levels = reverse.levels(&starts);

    let dependent = |&file: &usize| {
        let mut exposure = Exposure::Elsewhere;
        let mut calls = BTreeSet::new();
        for used in &graph.uses[file] {
            let Some(changed) = origins.get(&used.to) else {
                continue;
            };
            let (how, telling) = exposure_of(&used.calls, changed.as_ref().ok());
            exposure = exposure.min(how);
            calls.extend(telling.iter().map(|c| short_call(c)));
        }
        let tests = reverse.tests.direct(file);
        Dependent {
            file: graph.files[file].clone(),
            exposure,
            calls: calls.into_iter().collect(),
            protection: reverse.tests.protection(file, &reverse.sources[file]),
            tests: tests.len(),
            tests_in_diff: reverse.tests.own(file).filter(|&t| in_diff(t)).count(),
            entry_point: false,
            used: !reverse.sources[file].is_empty(),
        }
    };
    let mut direct: Vec<(usize, Dependent)> = levels
        .first()
        .into_iter()
        .flatten()
        .map(|file| (*file, dependent(file)))
        .collect();
    direct.sort_by(|(_, a), (_, b)| {
        // Most exposed first, and among those the least protected.
        (a.exposure, std::cmp::Reverse(a.protection), &a.file).cmp(&(
            b.exposure,
            std::cmp::Reverse(b.protection),
            &b.file,
        ))
    });

    let mut narrowing: Vec<Narrowing> = origins
        .iter()
        .map(|(&file, told)| {
            let path = &graph.files[file];
            Narrowing {
                file: path.clone(),
                functions: told.as_ref().ok().map(|f| f.iter().cloned().collect()),
                reason: told
                    .as_ref()
                    .err()
                    .map(|&why| reason(stat_of[path.as_path()], why)),
            }
        })
        .collect();
    narrowing.sort_by(|a, b| a.file.cmp(&b.file));
    let narrowed: Vec<&Vec<String>> = narrowing
        .iter()
        .filter_map(|n| n.functions.as_ref())
        .collect();
    let functions: Option<Vec<String>> = (!narrowed.is_empty()).then(|| {
        let all: BTreeSet<&String> = narrowed.into_iter().flatten().collect();
        all.into_iter().cloned().collect()
    });

    // The radius goes through the files that call what changed. One that
    // only calls functions the change leaves alone carries it no further,
    // and neither does one that merely refers to the module: a schema is
    // named by half a project, and following all of that says nothing.
    // Where an origin could not be narrowed, every use of it is a call.
    let calling: Vec<usize> = direct
        .iter()
        .filter(|(_, d)| d.exposure == Exposure::Calls)
        .map(|(file, _)| *file)
        .collect();
    let through = reverse.levels_through(&starts, &calling);

    Structural {
        origins: narrowing.iter().map(|n| n.file.clone()).collect(),
        functions,
        narrowing,
        source_files: roles.iter().filter(|r| **r == Role::Source).count(),
        direct: direct.into_iter().map(|(_, d)| d).collect(),
        by_distance: through.iter().map(Vec::len).collect(),
        reached_files: through
            .iter()
            .map(|level| level.iter().map(|&f| graph.files[f].clone()).collect())
            .collect(),
        upper_bound: levels.iter().map(Vec::len).sum(),
        unavailable: unavailable(&changed.iter().map(|c| c.path.as_path()).collect::<Vec<_>>()),
        ambiguous: graph.ambiguous,
    }
}

#[cfg(test)]
#[path = "structural_test.rs"]
mod tests;