Skip to main content

mati_core/analysis/
onboarding.rs

1//! Onboarding import (idea 2.2) — propose gotcha *candidates* by mining
2//! artifacts that already exist in a repo: CODEOWNERS ownership rules and
3//! load-bearing / security marker comments.
4//!
5//! Each candidate is a `confirmed: false` [`GotchaRecord`] stub
6//! (`RecordSource::Import`) that surfaces in `mati review` for a developer to
7//! approve — turning the blank-slate "confirm your gotchas" step into "here are
8//! N candidates we found." This module is **pure**: parsing and record
9//! construction take string content and emit [`Record`]s; file discovery and
10//! store I/O live in the `mati suggest` CLI command.
11
12use globset::{GlobBuilder, GlobSetBuilder};
13use uuid::Uuid;
14
15use crate::store::record::{
16    Category, ConfidenceScore, GotchaRecord, Priority, QualityScore, QualityTier, Record,
17    RecordSource,
18};
19
20/// Load-bearing / security markers we treat as strong, unambiguous signals.
21/// Deliberately narrow (no `TODO`/`FIXME`/`HACK`) to keep candidate quality high.
22const MARKERS: &[&str] = &[
23    "DO NOT REMOVE",
24    "DO NOT EDIT",
25    "DO NOT MODIFY",
26    "DO NOT DELETE",
27    "SECURITY:",
28    "SECURITY-CRITICAL",
29];
30
31/// Skip lines longer than this (minified / generated) to limit false positives.
32const MAX_LINE_LEN: usize = 400;
33
34/// Cap total marker candidates so a large repo can't flood `mati review`.
35pub const MAX_MARKER_CANDIDATES: usize = 200;
36
37/// Cap the number of concrete files attached to one CODEOWNERS candidate.
38pub const MAX_CODEOWNERS_AFFECTED_FILES: usize = 50;
39
40// ── CODEOWNERS ────────────────────────────────────────────────────────────────
41
42/// A parsed CODEOWNERS entry: a path pattern and its owners.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct OwnerRule {
45    pub pattern: String,
46    pub owners: Vec<String>,
47}
48
49/// Parse CODEOWNERS content into `(pattern, owners)` rules. Ignores comments
50/// (`#`) and blank lines; a valid line is `<pattern> <owner...>` with ≥1 owner.
51pub fn parse_codeowners(content: &str) -> Vec<OwnerRule> {
52    let mut rules = Vec::new();
53    for raw in content.lines() {
54        let line = raw.split('#').next().unwrap_or("").trim();
55        if line.is_empty() {
56            continue;
57        }
58        let mut parts = line.split_whitespace();
59        let Some(pattern) = parts.next() else {
60            continue;
61        };
62        let owners: Vec<String> = parts.map(str::to_string).collect();
63        if owners.is_empty() {
64            continue;
65        }
66        rules.push(OwnerRule {
67            pattern: pattern.to_string(),
68            owners,
69        });
70    }
71    rules
72}
73
74// ── Marker comments ───────────────────────────────────────────────────────────
75
76/// A marker-comment hit in a source file.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MarkerHit {
79    pub path: String,
80    pub line: usize,
81    pub marker: String,
82    pub text: String,
83}
84
85/// Scan one file's content for load-bearing / security markers (case-insensitive).
86pub fn scan_markers(path: &str, content: &str) -> Vec<MarkerHit> {
87    let mut hits = Vec::new();
88    for (i, raw) in content.lines().enumerate() {
89        if raw.len() > MAX_LINE_LEN {
90            continue;
91        }
92        let upper = raw.to_uppercase();
93        if let Some(marker) = MARKERS.iter().find(|m| upper.contains(**m)) {
94            hits.push(MarkerHit {
95                path: path.to_string(),
96                line: i + 1,
97                marker: (*marker).to_string(),
98                text: raw.trim().to_string(),
99            });
100        }
101    }
102    hits
103}
104
105// ── Candidate record construction ─────────────────────────────────────────────
106
107/// Build one `confirmed: false` gotcha candidate Record. Mirrors the Layer-0
108/// stub pattern used by `init`'s git-signal candidates.
109#[allow(clippy::too_many_arguments)]
110fn candidate_record(
111    key: String,
112    rule: String,
113    reason: String,
114    severity: Priority,
115    affected_files: Vec<String>,
116    tags: Vec<String>,
117    device_id: Uuid,
118    logical_clock: u64,
119    now: u64,
120) -> Record {
121    let gotcha = GotchaRecord {
122        rule: rule.clone(),
123        reason,
124        severity: severity.clone(),
125        affected_files,
126        ref_url: None,
127        discovered_session: now,
128        confirmed: false,
129        confirmed_content: Default::default(),
130    };
131    let mut rec = Record::layer0_file_stub(&key, device_id, logical_clock, now);
132    rec.category = Category::Gotcha;
133    rec.source = RecordSource::Import;
134    rec.priority = severity;
135    rec.value = rule;
136    rec.quality = QualityScore {
137        value: 0.50,
138        tier: QualityTier::Acceptable,
139        signals: vec![],
140        computed_at: now,
141    };
142    // `for_new_record(Import)` sits below the 0.80 "confirmed" floor, so the
143    // stub stays a candidate until a developer confirms it.
144    rec.confidence = ConfidenceScore::for_new_record(&RecordSource::Import);
145    rec.tags = tags;
146    rec.payload = serde_json::to_value(&gotcha).ok();
147    rec
148}
149
150/// Translate a CODEOWNERS pattern into a glob. Gitignore semantics: a leading
151/// `/` only anchors, a trailing `/` means "everything under", and a pattern
152/// with no `/` at all matches at any depth.
153fn codeowners_glob_pattern(pattern: &str) -> String {
154    let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
155    if pattern == "*" {
156        return "**".to_string();
157    }
158    if pattern.ends_with('/') {
159        return format!("{pattern}**");
160    }
161    if pattern.contains('/') {
162        pattern.to_string()
163    } else {
164        format!("**/{pattern}")
165    }
166}
167
168/// Expand one CODEOWNERS pattern into the concrete repo-relative paths it
169/// owns. `None` when nothing matches or the match set exceeds
170/// [`MAX_CODEOWNERS_AFFECTED_FILES`] — both mean "not a per-file gotcha".
171pub(crate) fn expand_codeowners_pattern(
172    pattern: &str,
173    repo_files: &[String],
174) -> Option<Vec<String>> {
175    // A pattern that names a directory owns everything under it, and nothing
176    // in the pattern says which it is — so match the path and its subtree.
177    let base = codeowners_glob_pattern(pattern);
178    let subtree = format!("{base}/**");
179    let mut builder = GlobSetBuilder::new();
180    for expr in [base.as_str(), subtree.as_str()] {
181        builder.add(
182            GlobBuilder::new(expr)
183                .literal_separator(false)
184                .build()
185                .ok()?,
186        );
187    }
188    let globset = builder.build().ok()?;
189
190    let mut matches: Vec<String> = repo_files
191        .iter()
192        .filter(|path| globset.is_match(path))
193        .cloned()
194        .collect();
195    matches.sort();
196    matches.dedup();
197    if matches.is_empty() || matches.len() > MAX_CODEOWNERS_AFFECTED_FILES {
198        None
199    } else {
200        Some(matches)
201    }
202}
203
204/// Candidate records from CODEOWNERS rules (ownership coordination gotchas).
205/// A rule whose pattern does not expand to a bounded set of real files is
206/// dropped: `affected_files` must hold paths the `file:*` index can match.
207pub fn codeowners_candidates(
208    rules: &[OwnerRule],
209    repo_files: &[String],
210    device_id: Uuid,
211    clock_start: u64,
212    now: u64,
213) -> Vec<Record> {
214    rules
215        .iter()
216        .enumerate()
217        .filter_map(|(i, r)| {
218            let affected_files = expand_codeowners_pattern(&r.pattern, repo_files)?;
219            let owners = r.owners.join(", ");
220            let rule = format!(
221                "`{}` is owned by {} (CODEOWNERS) — coordinate changes with them.",
222                r.pattern, owners
223            );
224            let reason = format!("Listed in CODEOWNERS: {} → {}.", r.pattern, owners);
225            let key = format!("gotcha:codeowners:{}", r.pattern);
226            Some(candidate_record(
227                key,
228                rule,
229                reason,
230                Priority::Normal,
231                affected_files,
232                vec!["codeowners".into(), "auto-generated".into()],
233                device_id,
234                clock_start + i as u64,
235                now,
236            ))
237        })
238        .collect()
239}
240
241/// Candidate records from marker hits (capped at [`MAX_MARKER_CANDIDATES`]).
242pub fn marker_candidates(
243    hits: &[MarkerHit],
244    device_id: Uuid,
245    clock_start: u64,
246    now: u64,
247) -> Vec<Record> {
248    hits.iter()
249        .take(MAX_MARKER_CANDIDATES)
250        .enumerate()
251        .map(|(i, h)| {
252            let rule = format!(
253                "`{}` carries a `{}` marker at line {} — preserve it through edits.",
254                h.path, h.marker, h.line
255            );
256            let reason = format!("Developer marker in source: {}", h.text);
257            let key = format!("gotcha:marker:{}:{}", h.path, h.line);
258            // Load-bearing / security markers are high severity by definition.
259            candidate_record(
260                key,
261                rule,
262                reason,
263                Priority::High,
264                vec![h.path.clone()],
265                vec!["code-marker".into(), "auto-generated".into()],
266                device_id,
267                clock_start + i as u64,
268                now,
269            )
270        })
271        .collect()
272}
273
274/// Build all onboarding candidates from already-read artifact content. Pure:
275/// `codeowners` is the CODEOWNERS file content (if found) and `files` is a list
276/// of `(repo-relative path, content)` pairs to scan for markers.
277pub fn build_candidates(
278    codeowners: Option<&str>,
279    files: &[(String, String)],
280    device_id: Uuid,
281    clock_start: u64,
282    now: u64,
283) -> Vec<Record> {
284    let mut out = Vec::new();
285    let mut clock = clock_start;
286
287    if let Some(content) = codeowners {
288        let rules = parse_codeowners(content);
289        let repo_files: Vec<String> = files.iter().map(|(path, _)| path.clone()).collect();
290        let recs = codeowners_candidates(&rules, &repo_files, device_id, clock, now);
291        // Clocks are assigned by rule index, so skipped rules leave gaps —
292        // advance past every rule, not just the ones that produced a record.
293        clock += rules.len() as u64;
294        out.extend(recs);
295    }
296
297    let mut hits = Vec::new();
298    for (path, content) in files {
299        hits.extend(scan_markers(path, content));
300    }
301    out.extend(marker_candidates(&hits, device_id, clock, now));
302
303    out
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn dev() -> Uuid {
311        Uuid::nil()
312    }
313
314    fn is_unconfirmed_gotcha(rec: &Record) -> bool {
315        rec.category == Category::Gotcha
316            && rec.source == RecordSource::Import
317            && rec
318                .payload
319                .as_ref()
320                .and_then(|p| serde_json::from_value::<GotchaRecord>(p.clone()).ok())
321                .is_some_and(|g| !g.confirmed)
322    }
323
324    #[test]
325    fn parse_codeowners_ignores_comments_and_blank_and_ownerless() {
326        let content = "\
327# comment\n\
328\n\
329src/payments/** @pay-team @alice\n\
330docs/   # trailing comment\n\
331*.rs @rustfolk\n";
332        let rules = parse_codeowners(content);
333        assert_eq!(rules.len(), 2, "ownerless `docs/` line is skipped");
334        assert_eq!(rules[0].pattern, "src/payments/**");
335        assert_eq!(rules[0].owners, vec!["@pay-team", "@alice"]);
336        assert_eq!(rules[1].pattern, "*.rs");
337    }
338
339    #[test]
340    fn scan_markers_is_case_insensitive_and_skips_long_lines() {
341        let content = "\
342let x = 1;\n\
343// do not remove: load-bearing init order\n\
344// SECURITY: validate before deref\n\
345let normal = 2;\n";
346        let hits = scan_markers("src/lib.rs", content);
347        assert_eq!(hits.len(), 2);
348        assert_eq!(hits[0].marker, "DO NOT REMOVE");
349        assert_eq!(hits[0].line, 2);
350        assert_eq!(hits[1].marker, "SECURITY:");
351
352        // Over-long (minified) lines are skipped.
353        let long = format!("// DO NOT REMOVE {}", "x".repeat(MAX_LINE_LEN));
354        assert!(scan_markers("min.js", &long).is_empty());
355    }
356
357    #[test]
358    fn codeowners_candidates_are_unconfirmed_gotchas_keyed_by_pattern() {
359        let rules = parse_codeowners("src/payments/** @pay-team\n");
360        let repo_files = vec![
361            "src/payments/card.rs".to_string(),
362            "src/payments/wallet.rs".to_string(),
363        ];
364        let recs = codeowners_candidates(&rules, &repo_files, dev(), 0, 100);
365        assert_eq!(recs.len(), 1);
366        assert!(is_unconfirmed_gotcha(&recs[0]));
367        assert_eq!(recs[0].key, "gotcha:codeowners:src/payments/**");
368        let g: GotchaRecord = serde_json::from_value(recs[0].payload.clone().unwrap()).unwrap();
369        assert_eq!(
370            g.affected_files,
371            vec![
372                "src/payments/card.rs".to_string(),
373                "src/payments/wallet.rs".to_string()
374            ]
375        );
376        assert!(!g.confirmed);
377    }
378
379    #[test]
380    fn codeowners_patterns_translate_to_globs() {
381        let cases = [
382            ("*", "**"),
383            ("docs/", "docs/**"),
384            ("/build/logs/", "build/logs/**"),
385            ("src/payments/**", "src/payments/**"),
386            ("*.js", "**/*.js"),
387            ("docs/API.md", "docs/API.md"),
388        ];
389        for (pattern, expected) in cases {
390            assert_eq!(codeowners_glob_pattern(pattern), expected);
391        }
392    }
393
394    #[test]
395    fn codeowners_expansion_matches_scoped_paths_and_skips_empty() {
396        let files = vec![
397            "docs/API.md".to_string(),
398            "docs/guide.md".to_string(),
399            "src/docs/guide.md".to_string(),
400            "src/main.rs".to_string(),
401        ];
402        assert_eq!(
403            expand_codeowners_pattern("docs/", &files),
404            Some(vec!["docs/API.md".to_string(), "docs/guide.md".to_string()])
405        );
406        assert_eq!(expand_codeowners_pattern("missing/*.rs", &files), None);
407        // A bare name carries no trailing slash, but still owns its subtree —
408        // and, being unanchored, every `docs` directory at any depth.
409        assert_eq!(
410            expand_codeowners_pattern("docs", &files),
411            Some(vec![
412                "docs/API.md".to_string(),
413                "docs/guide.md".to_string(),
414                "src/docs/guide.md".to_string()
415            ])
416        );
417        let rules = parse_codeowners("missing/*.rs @team\n");
418        assert!(codeowners_candidates(&rules, &files, dev(), 0, 100).is_empty());
419    }
420
421    #[test]
422    fn codeowners_expansion_skips_over_cap_and_repo_wide_star() {
423        let files: Vec<String> = (0..=MAX_CODEOWNERS_AFFECTED_FILES)
424            .map(|i| format!("src/f{i}.rs"))
425            .collect();
426        assert_eq!(expand_codeowners_pattern("src/**", &files), None);
427        assert_eq!(expand_codeowners_pattern("*", &files), None);
428        let over_cap = parse_codeowners("src/** @team\n");
429        let repo_wide = parse_codeowners("* @team\n");
430        assert!(codeowners_candidates(&over_cap, &files, dev(), 0, 100).is_empty());
431        assert!(codeowners_candidates(&repo_wide, &files, dev(), 0, 100).is_empty());
432    }
433
434    #[test]
435    fn marker_candidates_cap_and_key_format() {
436        // Build more hits than the cap.
437        let hits: Vec<MarkerHit> = (0..MAX_MARKER_CANDIDATES + 50)
438            .map(|i| MarkerHit {
439                path: format!("src/f{i}.rs"),
440                line: i + 1,
441                marker: "DO NOT REMOVE".into(),
442                text: "// DO NOT REMOVE".into(),
443            })
444            .collect();
445        let recs = marker_candidates(&hits, dev(), 0, 100);
446        assert_eq!(recs.len(), MAX_MARKER_CANDIDATES, "capped");
447        assert_eq!(recs[0].key, "gotcha:marker:src/f0.rs:1");
448        assert_eq!(recs[0].priority, Priority::High);
449        assert!(is_unconfirmed_gotcha(&recs[0]));
450    }
451
452    #[test]
453    fn build_candidates_combines_both_sources() {
454        let files = vec![(
455            "src/auth.rs".to_string(),
456            "// SECURITY: constant-time compare\n".to_string(),
457        )];
458        let recs = build_candidates(Some("src/** @team\n"), &files, dev(), 0, 100);
459        assert_eq!(recs.len(), 2);
460        assert!(recs.iter().all(is_unconfirmed_gotcha));
461        assert!(recs.iter().any(|r| r.key.starts_with("gotcha:codeowners:")));
462        assert!(recs.iter().any(|r| r.key.starts_with("gotcha:marker:")));
463        // Logical clocks are distinct (no collisions across sources).
464        let clocks: std::collections::HashSet<u64> =
465            recs.iter().map(|r| r.version.logical_clock).collect();
466        assert_eq!(clocks.len(), recs.len());
467
468        let codeowners = recs
469            .iter()
470            .find(|r| r.key == "gotcha:codeowners:src/**")
471            .unwrap();
472        let gotcha: GotchaRecord =
473            serde_json::from_value(codeowners.payload.clone().unwrap()).unwrap();
474        assert_eq!(gotcha.affected_files, vec!["src/auth.rs"]);
475    }
476}