Skip to main content

comindly_core/
conventions.rs

1//! Conventions engine: measured coding conventions per repo, with support counts, central
2//! exemplars and counterexamples, mined from facts and the graph. An LLM may phrase them; it
3//! never invents them. Conventions shared by several repos form the org baseline.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6use std::sync::Arc;
7
8use anyhow::{bail, Context, Result};
9use futures::TryStreamExt;
10use object_store::path::Path as ObjPath;
11use object_store::{ObjectStore, ObjectStoreExt, PutPayload};
12
13use crate::facts::{FileFacts, ImportItem, RefKind, ValueExpr};
14use crate::model::{Edge, EdgeKind, Symbol, SymbolKind};
15
16pub use comindly_api::conventions::ORG;
17
18pub use comindly_api::conventions::{Convention, Manual, Origin};
19
20pub use comindly_api::conventions::{and_list, measured_id, thousands, FEW, MANUAL};
21
22/// What a change to one convention writes.
23#[derive(Debug, PartialEq)]
24pub enum Write {
25    Put(Manual),
26    Delete(String),
27}
28
29pub enum Change {
30    Edit(String),
31    Hide,
32    /// Undo an edit or hiding of a measured convention.
33    Restore,
34}
35
36/// The write for `change` to the convention `id`; `current` is the applied list.
37pub fn change(
38    current: &[Convention],
39    manual: &[Manual],
40    id: &str,
41    change: Change,
42    actor: &str,
43) -> Result<Write> {
44    let own = manual.iter().find(|m| m.id == id);
45    if let Change::Restore = change {
46        return match own {
47            Some(m) if m.replaces.is_some() => Ok(Write::Delete(m.id.clone())),
48            _ => bail!("{id} is not an edited or hidden measured convention"),
49        };
50    }
51    let c = current
52        .iter()
53        .find(|c| c.id() == id)
54        .with_context(|| format!("no convention {id}"))?;
55    let base = || Manual {
56        id: id.to_string(),
57        repo: c.repo.clone(),
58        dimension: c.dimension.clone(),
59        rule: c.rule.clone(),
60        replaces: (c.origin != Origin::Manual).then(|| c.key.clone()),
61        hidden: false,
62        actor: actor.into(),
63        updated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
64    };
65    Ok(match change {
66        Change::Edit(rule) => Write::Put(Manual {
67            rule: rule.trim().into(),
68            ..base()
69        }),
70        Change::Hide if c.origin == Origin::Manual => Write::Delete(id.to_string()),
71        Change::Hide => Write::Put(Manual {
72            hidden: true,
73            rule: own.map_or(c.rule.clone(), |m| m.rule.clone()),
74            ..base()
75        }),
76        Change::Restore => unreachable!(),
77    })
78}
79
80/// Measured conventions with people's changes applied: hidden ones dropped, edits replace the
81/// rule, new ones appended.
82pub fn apply(mut measured: Vec<Convention>, manual: &[Manual]) -> Vec<Convention> {
83    let over: HashMap<(&str, &str), &Manual> = manual
84        .iter()
85        .filter_map(|m| Some(((m.repo.as_str(), m.replaces.as_deref()?), m)))
86        .collect();
87    measured.retain_mut(|c| match over.get(&(c.repo.as_str(), c.key.as_str())) {
88        Some(m) if m.hidden => false,
89        Some(m) => {
90            c.rule = m.rule.clone();
91            c.generic = m.rule.clone();
92            c.origin = Origin::Edited;
93            true
94        }
95        None => true,
96    });
97    measured.extend(
98        manual
99            .iter()
100            .filter(|m| m.replaces.is_none())
101            .map(|m| Convention {
102                repo: m.repo.clone(),
103                dimension: m.dimension.clone(),
104                key: format!("{MANUAL}{}", m.id),
105                rule: m.rule.clone(),
106                generic: m.rule.clone(),
107                support: 0,
108                total: 0,
109                unit: String::new(),
110                repos: Vec::new(),
111                exemplars: Vec::new(),
112                counterexamples: Vec::new(),
113                origin: Origin::Manual,
114            }),
115    );
116    measured
117}
118
119/// People's changes, one JSON object each under `<index_root>/_conventions/`; indexing never
120/// touches them.
121pub struct Store {
122    store: Arc<dyn ObjectStore>,
123    root: ObjPath,
124}
125
126impl Store {
127    pub fn open(index_root: &str) -> Result<Store> {
128        let (store, root) = crate::ledger::sub_store(index_root, "_conventions")?;
129        Ok(Store { store, root })
130    }
131
132    pub async fn all(&self) -> Result<Vec<Manual>> {
133        let metas: Vec<_> = self.store.list(Some(&self.root)).try_collect().await?;
134        let mut out = Vec::new();
135        for m in metas
136            .iter()
137            .filter(|m| m.location.as_ref().ends_with(".json"))
138        {
139            let bytes = self.store.get(&m.location).await?.bytes().await?;
140            match serde_json::from_slice::<Manual>(&bytes) {
141                Ok(c) => out.push(c),
142                Err(e) => eprintln!(
143                    "comindly: skipping unreadable convention {}: {e}",
144                    m.location
145                ),
146            }
147        }
148        out.sort_by(|a, b| a.id.cmp(&b.id));
149        Ok(out)
150    }
151
152    pub async fn write(&self, w: &Write) -> Result<()> {
153        match w {
154            Write::Put(m) => {
155                m.validate()?;
156                let key = self.root.clone().join(format!("{}.json", m.id));
157                let body = PutPayload::from(serde_json::to_vec_pretty(m)?);
158                self.store
159                    .put(&key, body)
160                    .await
161                    .context("write convention")?;
162            }
163            Write::Delete(id) => {
164                let key = self.root.clone().join(format!("{id}.json"));
165                self.store.delete(&key).await.context("delete convention")?;
166            }
167        }
168        Ok(())
169    }
170}
171
172pub fn manual_blocking(index_root: &str) -> Result<Vec<Manual>> {
173    crate::rt::block_on(Store::open(index_root)?.all())
174}
175
176pub fn write_blocking(index_root: &str, w: &Write) -> Result<()> {
177    crate::rt::block_on(Store::open(index_root)?.write(w))
178}
179
180/// External libraries commonly wrapped by internal modules.
181const WRAPPABLE: &[&str] = &[
182    "httpx",
183    "requests",
184    "aiohttp",
185    "boto3",
186    "aiobotocore",
187    "botocore",
188    "logging",
189    "structlog",
190    "sqlalchemy",
191    "psycopg",
192    "psycopg2",
193    "asyncpg",
194    "redis",
195    "openai",
196    "anthropic",
197    "litellm",
198    "axios",
199];
200const TEST_LIBS: &[&str] = &[
201    "pytest",
202    "unittest",
203    "vitest",
204    "jest",
205    "@testing-library/react",
206    "mocha",
207    "hypothesis",
208];
209const MOCK_LIBS: &[&str] = &[
210    "unittest.mock",
211    "pytest_mock",
212    "respx",
213    "moto",
214    "freezegun",
215    "responses",
216    "sinon",
217    "msw",
218    "nock",
219    "fakeredis",
220    "pytest_httpx",
221];
222
223pub fn mine(symbols: &[Symbol], edges: &[Edge], facts: &[FileFacts]) -> Vec<Convention> {
224    let cx = Cx::new(symbols, edges, facts);
225    let mut out = Vec::new();
226    let mut repos: Vec<&str> = facts.iter().map(|f| f.repo.as_str()).collect();
227    repos.sort();
228    repos.dedup();
229    for repo in repos {
230        out.extend(cx.naming(repo));
231        out.extend(cx.signatures(repo));
232        out.extend(cx.bases(repo));
233        out.extend(cx.layout(repo));
234        out.extend(cx.logging(repo));
235        out.extend(cx.config(repo));
236        out.extend(cx.libraries(repo));
237        out.extend(cx.testing(repo));
238    }
239    let org = org_baseline(&out);
240    out.extend(org);
241    out
242}
243
244/// Conventions whose key holds in at least two repos.
245fn org_baseline(per_repo: &[Convention]) -> Vec<Convention> {
246    let mut by_key: BTreeMap<&str, Vec<&Convention>> = BTreeMap::new();
247    for c in per_repo {
248        by_key.entry(c.key.as_str()).or_default().push(c);
249    }
250    let repos: HashSet<&str> = per_repo.iter().map(|c| c.repo.as_str()).collect();
251    by_key
252        .into_iter()
253        .filter(|(_, cs)| cs.len() >= 2)
254        .map(|(key, cs)| {
255            let names: Vec<&str> = cs.iter().map(|c| c.repo.as_str()).collect();
256            // The fullest wording, so a layout rule keeps its example directory.
257            let generic = cs
258                .iter()
259                .map(|c| &c.generic)
260                .max_by_key(|g| g.len())
261                .unwrap();
262            Convention {
263                origin: Origin::Measured,
264                repo: ORG.into(),
265                dimension: cs[0].dimension.clone(),
266                key: key.to_string(),
267                rule: generic.clone(),
268                generic: generic.clone(),
269                support: cs.len(),
270                total: repos.len(),
271                unit: "measured repositories".into(),
272                repos: names.iter().map(|n| n.to_string()).collect(),
273                // `repo:path:line`, since the baseline spans repositories.
274                exemplars: cs
275                    .iter()
276                    .flat_map(|c| c.exemplars.first().map(|e| format!("{}:{e}", c.repo)))
277                    .take(3)
278                    .collect(),
279                counterexamples: Vec::new(),
280            }
281        })
282        .collect()
283}
284
285struct Cx<'a> {
286    syms: Vec<&'a Symbol>,
287    by_id: HashMap<String, &'a Symbol>,
288    facts: &'a [FileFacts],
289    /// Incoming dependency edges per symbol id (centrality for exemplars).
290    dependents: HashMap<String, usize>,
291    /// Outgoing edges by kind per source id.
292    out_edges: HashMap<(String, EdgeKind), Vec<String>>,
293    /// Internal module names (Python dotted, JS specifiers).
294    modules: HashSet<&'a str>,
295    /// Internal module → external libraries it imports.
296    wraps: HashMap<&'a str, HashSet<String>>,
297    /// Callables with at least one typed parameter or local.
298    typed: HashSet<&'a str>,
299    /// Callables with a declared return type.
300    returns: HashSet<&'a str>,
301}
302
303impl<'a> Cx<'a> {
304    fn new(symbols: &'a [Symbol], edges: &'a [Edge], facts: &'a [FileFacts]) -> Self {
305        let mut dependents: HashMap<String, usize> = HashMap::new();
306        let mut out_edges: HashMap<(String, EdgeKind), Vec<String>> = HashMap::new();
307        for e in edges {
308            if crate::graph::DEPENDENCY.contains(&e.kind) {
309                *dependents.entry(e.dst.render()).or_default() += 1;
310            }
311            out_edges
312                .entry((e.src.render(), e.kind.clone()))
313                .or_default()
314                .push(e.dst.render());
315        }
316        let modules: HashSet<&str> = facts
317            .iter()
318            .flat_map(|f| f.modules.iter().map(String::as_str))
319            .collect();
320        let mut wraps: HashMap<&str, HashSet<String>> = HashMap::new();
321        let (mut typed, mut returns) = (HashSet::new(), HashSet::new());
322        for f in facts {
323            for t in &f.types {
324                if t.name == crate::parse::RETURN {
325                    returns.insert(t.scope.as_str());
326                } else {
327                    typed.insert(t.scope.as_str());
328                }
329            }
330            let ext: HashSet<String> = f
331                .imports
332                .iter()
333                .filter_map(|i| external_root(&i.module, &modules))
334                .collect();
335            if !ext.is_empty() {
336                for m in &f.modules {
337                    wraps
338                        .entry(m.as_str())
339                        .or_default()
340                        .extend(ext.iter().cloned());
341                }
342            }
343        }
344        Cx {
345            syms: symbols
346                .iter()
347                .filter(|s| s.kind != SymbolKind::Contract)
348                .collect(),
349            by_id: symbols.iter().map(|s| (s.id.render(), s)).collect(),
350            facts,
351            dependents,
352            out_edges,
353            modules,
354            wraps,
355            typed,
356            returns,
357        }
358    }
359
360    fn repo_syms<'s>(&'s self, repo: &'s str) -> impl Iterator<Item = &'a Symbol> + 's {
361        self.syms
362            .iter()
363            .copied()
364            .filter(move |s| s.repo.0 == repo && !is_test(&s.file_path))
365    }
366
367    fn repo_facts<'s>(&'s self, repo: &'s str) -> impl Iterator<Item = &'a FileFacts> + 's {
368        self.facts.iter().filter(move |f| f.repo == repo)
369    }
370
371    /// Locations of the most central symbols first.
372    fn central(&self, mut v: Vec<&Symbol>, n: usize) -> Vec<String> {
373        v.sort_by_key(|s| {
374            std::cmp::Reverse(self.dependents.get(&s.id.render()).copied().unwrap_or(0))
375        });
376        v.into_iter().take(n).map(loc).collect()
377    }
378
379    fn outgoing(&self, id: &str, kind: EdgeKind) -> impl Iterator<Item = &'a Symbol> + '_ {
380        self.out_edges
381            .get(&(id.to_string(), kind))
382            .into_iter()
383            .flatten()
384            .filter_map(|d| self.by_id.get(d).copied())
385    }
386
387    fn naming(&self, repo: &str) -> Vec<Convention> {
388        let groups: [(&str, &[SymbolKind]); 3] = [
389            ("functions", &[SymbolKind::Function, SymbolKind::Method]),
390            (
391                "types",
392                &[
393                    SymbolKind::Class,
394                    SymbolKind::Interface,
395                    SymbolKind::Struct,
396                    SymbolKind::Trait,
397                    SymbolKind::Enum,
398                ],
399            ),
400            ("constants", &[SymbolKind::Constant]),
401        ];
402        let mut out = Vec::new();
403        for (label, kinds) in groups {
404            let named: Vec<&Symbol> = self
405                .repo_syms(repo)
406                .filter(|s| kinds.contains(&s.kind))
407                .filter(|s| !(s.name.starts_with("__") && s.name.ends_with("__")))
408                .collect();
409            if named.len() < 10 {
410                continue;
411            }
412            let mut hist: BTreeMap<&str, Vec<&Symbol>> = BTreeMap::new();
413            for s in &named {
414                hist.entry(casing(&s.name)).or_default().push(s);
415            }
416            let Some((case, hits)) = hist.iter().max_by_key(|(_, v)| v.len()) else {
417                continue;
418            };
419            if hits.len() * 10 < named.len() * 8 {
420                continue;
421            }
422            let deviants: Vec<&Symbol> = named
423                .iter()
424                .filter(|s| casing(&s.name) != *case)
425                .copied()
426                .collect();
427            let generic = format!("Name {label} in `{case}`");
428            out.push(Convention {
429                origin: Origin::Measured,
430                repo: repo.into(),
431                dimension: "naming".into(),
432                key: format!("naming:{label}:{case}"),
433                rule: generic.clone(),
434                generic,
435                support: hits.len(),
436                total: named.len(),
437                unit: label.to_string(),
438                repos: Vec::new(),
439                exemplars: self.central(hits.clone(), 3),
440                counterexamples: self.central(deviants, 3),
441            });
442        }
443        out
444    }
445
446    fn signatures(&self, repo: &str) -> Vec<Convention> {
447        let callables: Vec<&Symbol> = self
448            .repo_syms(repo)
449            .filter(|s| matches!(s.kind, SymbolKind::Function | SymbolKind::Method))
450            .filter(|s| s.name != "__init__" && s.name != "constructor")
451            .collect();
452        if callables.len() < 10 {
453            return Vec::new();
454        }
455        let mut out = Vec::new();
456        let (annotated, bare): (Vec<&Symbol>, Vec<&Symbol>) = callables
457            .iter()
458            .partition(|s| self.returns.contains(s.id.render().as_str()));
459        let share = annotated.len() * 100 / callables.len();
460        if share >= 60 || share <= 20 {
461            let (generic, key, ex, dev) = if share >= 60 {
462                (
463                    "Declare return types",
464                    "signatures:return-types:annotated",
465                    annotated,
466                    bare,
467                )
468            } else {
469                (
470                    "Leave return types unannotated",
471                    "signatures:return-types:bare",
472                    bare,
473                    annotated,
474                )
475            };
476            out.push(Convention {
477                origin: Origin::Measured,
478                repo: repo.into(),
479                dimension: "signatures".into(),
480                key: key.into(),
481                rule: generic.into(),
482                generic: generic.into(),
483                support: ex.len(),
484                total: callables.len(),
485                unit: "functions".into(),
486                repos: Vec::new(),
487                exemplars: self.central(ex, 3),
488                counterexamples: self.central(dev, 3),
489            });
490        }
491        let with_params: Vec<&Symbol> = callables
492            .iter()
493            .filter(|s| s.signature.as_deref().is_some_and(has_params))
494            .copied()
495            .collect();
496        let (typed, untyped): (Vec<&Symbol>, Vec<&Symbol>) = with_params
497            .iter()
498            .partition(|s| self.typed.contains(s.id.render().as_str()));
499        if with_params.len() >= 10 && typed.len() * 10 >= with_params.len() * 7 {
500            let generic = "Type-annotate parameters";
501            out.push(Convention {
502                origin: Origin::Measured,
503                repo: repo.into(),
504                dimension: "signatures".into(),
505                key: "signatures:params:typed".into(),
506                rule: generic.into(),
507                generic: generic.into(),
508                support: typed.len(),
509                total: with_params.len(),
510                unit: "functions with parameters".into(),
511                repos: Vec::new(),
512                exemplars: self.central(typed, 3),
513                counterexamples: self.central(untyped, 3),
514            });
515        }
516        let asyncs: Vec<&Symbol> = callables
517            .iter()
518            .filter(|s| {
519                s.signature
520                    .as_deref()
521                    .is_some_and(|g| g.trim_start().starts_with("async "))
522            })
523            .copied()
524            .collect();
525        if asyncs.len() * 2 >= callables.len() {
526            let generic = "Write functions as `async`";
527            out.push(Convention {
528                origin: Origin::Measured,
529                repo: repo.into(),
530                dimension: "signatures".into(),
531                key: "signatures:async".into(),
532                rule: generic.into(),
533                generic: generic.into(),
534                support: asyncs.len(),
535                total: callables.len(),
536                unit: "functions".into(),
537                repos: Vec::new(),
538                exemplars: self.central(asyncs, 3),
539                counterexamples: Vec::new(),
540            });
541        }
542        out
543    }
544
545    /// The topmost internal error class above `class` (`NotFoundError` → `AcmeError`).
546    fn error_root(&self, class: &'a Symbol) -> &'a Symbol {
547        let mut cur = class;
548        for _ in 0..8 {
549            let parent = self
550                .outgoing(&cur.id.render(), EdgeKind::Inherits)
551                .find(|p| is_error_name(&p.name));
552            match parent {
553                Some(p) => cur = p,
554                None => break,
555            }
556        }
557        cur
558    }
559
560    /// Base classes: data models, errors, and other shared bases.
561    fn bases(&self, repo: &str) -> Vec<Convention> {
562        let mut by_base: BTreeMap<String, Vec<&Symbol>> = BTreeMap::new();
563        let mut errors: BTreeMap<String, Vec<&Symbol>> = BTreeMap::new();
564        for f in self.repo_facts(repo) {
565            for r in f
566                .refs
567                .iter()
568                .filter(|r| matches!(r.kind, RefKind::Inherits | RefKind::Implements))
569            {
570                let Some(class) = self.by_id.get(&r.from).copied() else {
571                    continue;
572                };
573                if is_test(&class.file_path) {
574                    continue;
575                }
576                let internal = self
577                    .outgoing(&r.from, EdgeKind::Inherits)
578                    .chain(self.outgoing(&r.from, EdgeKind::Implements))
579                    .find(|b| Some(&b.name) == r.path.last());
580                let base = match internal {
581                    Some(b) => label(&b.name, Some(&b.repo.0)),
582                    None => match external_name(f, &r.path, &self.modules) {
583                        Some(n) => label(&n, None),
584                        None => continue,
585                    },
586                };
587                if is_error_name(r.path.last().map_or("", String::as_str)) {
588                    let root = match internal {
589                        Some(b) => {
590                            let root = self.error_root(b);
591                            label(&root.name, Some(&root.repo.0))
592                        }
593                        None => base.clone(),
594                    };
595                    errors.entry(root).or_default().push(class);
596                } else {
597                    by_base.entry(base).or_default().push(class);
598                }
599            }
600        }
601        let mut out = Vec::new();
602        let mut models: Vec<(String, Vec<&Symbol>)> =
603            by_base.into_iter().filter(|(_, c)| c.len() >= 3).collect();
604        models.sort_by_key(|(_, c)| std::cmp::Reverse(c.len()));
605        for (base, classes) in models.into_iter().take(4) {
606            let generic = format!("Extend {base} for new classes of its kind");
607            out.push(Convention {
608                origin: Origin::Measured,
609                repo: repo.into(),
610                dimension: "models".into(),
611                key: format!("models:base:{base}"),
612                rule: generic.clone(),
613                generic,
614                support: classes.len(),
615                total: classes.len(),
616                unit: "classes".into(),
617                repos: Vec::new(),
618                exemplars: self.central(classes, 3),
619                counterexamples: Vec::new(),
620            });
621        }
622        let total: usize = errors.values().map(Vec::len).sum();
623        if let Some((root, classes)) = errors.iter().max_by_key(|(_, c)| c.len()) {
624            if total >= 3 && classes.len() * 2 >= total {
625                let deviants: Vec<&Symbol> = errors
626                    .iter()
627                    .filter(|(r, _)| *r != root)
628                    .flat_map(|(_, c)| c.iter().copied())
629                    .collect();
630                let generic = format!("Derive custom errors from {root}");
631                out.push(Convention {
632                    origin: Origin::Measured,
633                    repo: repo.into(),
634                    dimension: "errors".into(),
635                    key: format!("errors:root:{root}"),
636                    rule: generic.clone(),
637                    generic,
638                    support: classes.len(),
639                    total,
640                    unit: "custom errors".into(),
641                    repos: Vec::new(),
642                    exemplars: self.central(classes.clone(), 3),
643                    counterexamples: self.central(deviants, 3),
644                });
645            }
646        }
647        out
648    }
649
650    /// Where each role lives: route handlers, HTTP clients, database queries.
651    fn layout(&self, repo: &str) -> Vec<Convention> {
652        let role_of = |kinds: &[EdgeKind]| -> Vec<&Symbol> {
653            self.repo_syms(repo)
654                .filter(|s| {
655                    kinds
656                        .iter()
657                        .any(|k| self.out_edges.contains_key(&(s.id.render(), k.clone())))
658                })
659                .collect()
660        };
661        let roles = [
662            ("HTTP route handlers", role_of(&[EdgeKind::Serves])),
663            ("HTTP client calls", role_of(&[EdgeKind::Consumes])),
664            (
665                "Database queries",
666                role_of(&[EdgeKind::Reads, EdgeKind::Writes]),
667            ),
668        ];
669        let mut out = Vec::new();
670        for (label, syms) in roles {
671            if syms.len() < 3 {
672                continue;
673            }
674            let mut dirs: BTreeMap<String, Vec<&Symbol>> = BTreeMap::new();
675            for s in &syms {
676                dirs.entry(dir_of(&s.file_path)).or_default().push(s);
677            }
678            let Some((dir, hits)) = dirs.iter().max_by_key(|(_, v)| v.len()) else {
679                continue;
680            };
681            if hits.len() * 2 < syms.len() {
682                continue;
683            }
684            let deviants: Vec<&Symbol> = syms
685                .iter()
686                .filter(|s| &dir_of(&s.file_path) != dir)
687                .copied()
688                .collect();
689            let mut tail: Vec<&str> = dir.rsplit('/').take(2).collect();
690            tail.reverse();
691            out.push(Convention {
692                origin: Origin::Measured,
693                repo: repo.into(),
694                dimension: "layout".into(),
695                key: format!("layout:{}", label.to_lowercase().replace(' ', "-")),
696                rule: if dir == "." {
697                    format!("Put {} at the top level of the repository", lower(label))
698                } else {
699                    format!("Put {} in `{dir}/`", lower(label))
700                },
701                generic: if dir == "." {
702                    format!("Put {} in one directory per service", lower(label))
703                } else {
704                    format!(
705                        "Put {} in one directory per service (e.g. `…/{}/`)",
706                        lower(label),
707                        tail.join("/")
708                    )
709                },
710                support: hits.len(),
711                total: syms.len(),
712                unit: lower(label),
713                repos: Vec::new(),
714                exemplars: self.central(hits.clone(), 3),
715                counterexamples: self.central(deviants, 3),
716            });
717        }
718        out
719    }
720
721    /// Which logger factory module code calls.
722    fn logging(&self, repo: &str) -> Vec<Convention> {
723        let mut by_factory: BTreeMap<String, Vec<String>> = BTreeMap::new();
724        for f in self.repo_facts(repo).filter(|f| !is_test(&f.path)) {
725            for r in f.refs.iter().filter(|r| r.kind == RefKind::Call) {
726                let Some(last) = r.path.last() else { continue };
727                if !matches!(
728                    last.as_str(),
729                    "getLogger" | "get_logger" | "getlogger" | "create_logger"
730                ) {
731                    continue;
732                }
733                let name = external_name(f, &r.path, &self.modules)
734                    .or_else(|| internal_name(f, &r.path))
735                    .unwrap_or_else(|| r.path.join("."));
736                let provider = name
737                    .rsplit_once('.')
738                    .map_or(name.clone(), |(m, _)| m.to_string());
739                by_factory
740                    .entry(provider)
741                    .or_default()
742                    .push(format!("{}:{}", f.path, r.line));
743            }
744        }
745        dominant(
746            repo,
747            "logging",
748            "logging:factory",
749            by_factory,
750            |name| format!("Get loggers from `{name}`"),
751            "logger calls",
752        )
753    }
754
755    /// Settings classes vs direct environment reads.
756    fn config(&self, repo: &str) -> Vec<Convention> {
757        let mut env_reads: Vec<String> = self
758            .repo_facts(repo)
759            .filter(|f| !is_test(&f.path))
760            .flat_map(|f| {
761                f.values
762                    .iter()
763                    .filter(|v| matches!(v.value, ValueExpr::Env(_)))
764                    .map(move |_| f.path.clone())
765            })
766            .collect();
767        let settings: Vec<&Symbol> = self
768            .repo_syms(repo)
769            .filter(|s| s.kind == SymbolKind::Class)
770            .filter(|s| {
771                let id = s.id.render();
772                self.facts.iter().any(|f| {
773                    f.refs.iter().any(|r| {
774                        r.from == id
775                            && r.kind == RefKind::Inherits
776                            && r.path
777                                .last()
778                                .is_some_and(|b| b.ends_with("Settings") || b.ends_with("Config"))
779                    })
780                })
781            })
782            .collect();
783        if settings.is_empty() && env_reads.len() < 3 {
784            return Vec::new();
785        }
786        let n_env = env_reads.len();
787        env_reads.sort();
788        env_reads.dedup();
789        let (generic, key, rule) = if settings.is_empty() {
790            let g = "Read configuration from environment variables";
791            (g, "config:env", g.to_string())
792        } else {
793            let g = "Keep configuration in settings classes";
794            let names: Vec<String> = settings
795                .iter()
796                .take(3)
797                .map(|s| format!("`{}`", s.name))
798                .collect();
799            (
800                g,
801                "config:settings-classes",
802                format!("{g}, such as {}", names.join(", ")),
803            )
804        };
805        vec![Convention {
806            origin: Origin::Measured,
807            repo: repo.into(),
808            dimension: "config".into(),
809            key: key.into(),
810            rule,
811            generic: generic.into(),
812            support: settings.len().max(n_env),
813            total: settings.len() + n_env,
814            unit: "settings classes and environment reads".into(),
815            repos: Vec::new(),
816            exemplars: self.central(settings, 3),
817            counterexamples: env_reads.into_iter().take(3).collect(),
818        }]
819    }
820
821    /// Internal wrappers preferred over direct third-party use (`acme.aws` over `boto3`).
822    fn libraries(&self, repo: &str) -> Vec<Convention> {
823        let mut direct: BTreeMap<String, Vec<String>> = BTreeMap::new();
824        let mut wrapped: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
825        for f in self.repo_facts(repo).filter(|f| !is_test(&f.path)) {
826            let own: HashSet<&str> = f.modules.iter().map(String::as_str).collect();
827            for imp in &f.imports {
828                if let Some(ext) = external_root(&imp.module, &self.modules) {
829                    if WRAPPABLE.contains(&ext.as_str()) {
830                        direct.entry(ext).or_default().push(f.path.clone());
831                    }
832                    continue;
833                }
834                let Some(target) =
835                    internal_module(&imp.module, &self.modules).filter(|t| !own.contains(t))
836                else {
837                    continue;
838                };
839                for ext in self.wraps.get(target).into_iter().flatten() {
840                    if WRAPPABLE.contains(&ext.as_str()) {
841                        *wrapped
842                            .entry(ext.clone())
843                            .or_default()
844                            .entry(target.to_string())
845                            .or_default() += 1;
846                    }
847                }
848            }
849        }
850        let mut out = Vec::new();
851        for (ext, wrappers) in wrapped {
852            let Some((wrapper, n)) = wrappers.iter().max_by_key(|(_, n)| **n) else {
853                continue;
854            };
855            let mut d = direct.get(&ext).cloned().unwrap_or_default();
856            d.sort();
857            d.dedup();
858            // The wrapper's own files don't count as deviations.
859            d.retain(|p| {
860                !self
861                    .modules_of(p)
862                    .iter()
863                    .any(|m| m.starts_with(wrapper.as_str()))
864            });
865            if *n < 3 || *n <= d.len() {
866                continue;
867            }
868            let generic = format!("Use `{ext}` through `{wrapper}`, not directly");
869            out.push(Convention {
870                origin: Origin::Measured,
871                repo: repo.into(),
872                dimension: "libraries".into(),
873                key: format!("libraries:{ext}:via:{wrapper}"),
874                rule: generic.clone(),
875                generic,
876                support: *n,
877                total: n + d.len(),
878                unit: format!("imports of `{ext}`"),
879                repos: Vec::new(),
880                exemplars: Vec::new(),
881                counterexamples: d.into_iter().take(3).collect(),
882            });
883        }
884        out
885    }
886
887    fn modules_of(&self, path: &str) -> Vec<String> {
888        self.facts
889            .iter()
890            .filter(|f| f.path == path)
891            .flat_map(|f| f.modules.clone())
892            .collect()
893    }
894
895    fn testing(&self, repo: &str) -> Vec<Convention> {
896        let tests: Vec<&FileFacts> = self.repo_facts(repo).filter(|f| is_test(&f.path)).collect();
897        if tests.len() < 3 {
898            return Vec::new();
899        }
900        let mut fw: BTreeMap<&str, Vec<String>> = BTreeMap::new();
901        let mut mocks: BTreeMap<&str, usize> = BTreeMap::new();
902        for f in &tests {
903            let uses = |lib: &str| {
904                f.imports
905                    .iter()
906                    .any(|i| i.module == lib || i.module.starts_with(&format!("{lib}.")))
907            };
908            for lib in TEST_LIBS.iter().filter(|l| uses(l)) {
909                fw.entry(lib).or_default().push(f.path.clone());
910            }
911            for lib in MOCK_LIBS.iter().filter(|l| uses(l)) {
912                *mocks.entry(lib).or_default() += 1;
913            }
914        }
915        let Some((lib, files)) = fw.iter().max_by_key(|(_, v)| v.len()) else {
916            return Vec::new();
917        };
918        let generic = format!("Write tests with `{lib}`");
919        let mut rule = generic.clone();
920        let mock_list: Vec<String> = mocks.keys().map(|m| format!("`{m}`")).collect();
921        if !mock_list.is_empty() {
922            rule.push_str(&format!("; mock with {}", mock_list.join(" or ")));
923        }
924        if tests.iter().any(|f| f.path.ends_with("conftest.py")) {
925            rule.push_str("; share fixtures in `conftest.py`");
926        }
927        let mut ex = files.clone();
928        ex.sort_by_key(|p| p.len());
929        vec![Convention {
930            origin: Origin::Measured,
931            repo: repo.into(),
932            dimension: "testing".into(),
933            key: format!("testing:{lib}"),
934            rule,
935            generic,
936            support: files.len(),
937            total: tests.len(),
938            unit: "test files".into(),
939            repos: Vec::new(),
940            exemplars: ex.into_iter().take(3).collect(),
941            counterexamples: Vec::new(),
942        }]
943    }
944}
945
946fn lower(label: &str) -> String {
947    match label.split_once(' ') {
948        Some((first, rest)) if first.chars().all(|c| c.is_uppercase()) => format!("{first} {rest}"),
949        _ => label.to_lowercase(),
950    }
951}
952
953/// The most common choice of a dimension, if it dominates (≥ 60%).
954fn dominant(
955    repo: &str,
956    dimension: &str,
957    key: &str,
958    choices: BTreeMap<String, Vec<String>>,
959    generic: impl Fn(&str) -> String,
960    unit: &str,
961) -> Vec<Convention> {
962    let total: usize = choices.values().map(Vec::len).sum();
963    let Some((name, sites)) = choices.iter().max_by_key(|(_, v)| v.len()) else {
964        return Vec::new();
965    };
966    if total < 3 || sites.len() * 10 < total * 6 {
967        return Vec::new();
968    }
969    let deviants: Vec<String> = choices
970        .iter()
971        .filter(|(n, _)| *n != name)
972        .flat_map(|(_, v)| v.iter().cloned())
973        .take(3)
974        .collect();
975    let g = generic(name);
976    vec![Convention {
977        origin: Origin::Measured,
978        repo: repo.into(),
979        dimension: dimension.into(),
980        key: format!("{key}:{name}"),
981        rule: g.clone(),
982        generic: g,
983        support: sites.len(),
984        total,
985        unit: unit.into(),
986        repos: Vec::new(),
987        exemplars: sites.iter().take(3).cloned().collect(),
988        counterexamples: deviants,
989    }]
990}
991
992/// `` `Name` (repo) `` for internal bases, `` `module.Name` `` for external ones.
993fn label(name: &str, repo: Option<&str>) -> String {
994    match repo {
995        Some(r) => format!("`{name}` ({r})"),
996        None => format!("`{name}`"),
997    }
998}
999
1000fn is_error_name(name: &str) -> bool {
1001    name.ends_with("Error") || name.ends_with("Exception")
1002}
1003
1004fn loc(s: &Symbol) -> String {
1005    format!("{}:{}", s.file_path, s.range.start.line)
1006}
1007
1008fn dir_of(path: &str) -> String {
1009    path.rsplit_once('/').map_or(".", |(d, _)| d).to_string()
1010}
1011
1012pub fn is_test(path: &str) -> bool {
1013    let p = path.to_ascii_lowercase();
1014    p.contains("/tests/")
1015        || p.starts_with("tests/")
1016        || p.contains("/test/")
1017        || p.contains("test_")
1018        || p.contains("_test.")
1019        || p.contains(".test.")
1020        || p.contains(".spec.")
1021        || p.ends_with("conftest.py")
1022}
1023
1024fn has_params(sig: &str) -> bool {
1025    let inner = sig
1026        .split_once('(')
1027        .and_then(|(_, r)| r.split_once(')'))
1028        .map_or("", |(a, _)| a);
1029    inner
1030        .split(',')
1031        .map(str::trim)
1032        .any(|p| !p.is_empty() && !matches!(p, "self" | "cls" | "this"))
1033}
1034
1035/// The first segment of a third-party import (`boto3`, `@scope/pkg`), or `None` if internal.
1036fn external_root(module: &str, internal: &HashSet<&str>) -> Option<String> {
1037    if module.is_empty() || module.starts_with('.') || internal_module(module, internal).is_some() {
1038        return None;
1039    }
1040    if module.starts_with('@') {
1041        return Some(module.splitn(3, '/').take(2).collect::<Vec<_>>().join("/"));
1042    }
1043    Some(module.split(['.', '/']).next()?.to_string())
1044}
1045
1046/// The internal module an import names (longest known prefix).
1047fn internal_module<'m>(module: &str, internal: &HashSet<&'m str>) -> Option<&'m str> {
1048    let mut m = module;
1049    loop {
1050        if let Some(found) = internal.get(m) {
1051            return Some(found);
1052        }
1053        m = &m[..m.rfind(['.', '/'])?];
1054    }
1055}
1056
1057/// `logging.getLogger` from a path whose head is bound by an import of a third-party module.
1058fn external_name(f: &FileFacts, path: &[String], internal: &HashSet<&str>) -> Option<String> {
1059    let (head, rest) = path.split_first()?;
1060    let base = f.imports.iter().find_map(|imp| {
1061        imp.items.iter().find_map(|item| match item {
1062            ImportItem::Name { name, alias } if alias.as_deref().unwrap_or(name) == head => {
1063                Some(format!("{}.{name}", imp.module))
1064            }
1065            ImportItem::Module { alias: Some(a) } if a == head => Some(imp.module.clone()),
1066            ImportItem::Module { alias: None }
1067                if imp.module.split('.').next() == Some(head.as_str()) =>
1068            {
1069                Some(head.clone())
1070            }
1071            _ => None,
1072        })
1073    })?;
1074    internal_module(&base, internal).is_none().then(|| {
1075        let mut parts = vec![base];
1076        parts.extend(rest.iter().cloned());
1077        parts.join(".")
1078    })
1079}
1080
1081/// Like [`external_name`] for internal targets.
1082fn internal_name(f: &FileFacts, path: &[String]) -> Option<String> {
1083    let (head, rest) = path.split_first()?;
1084    let base = f.imports.iter().find_map(|imp| {
1085        imp.items.iter().find_map(|item| match item {
1086            ImportItem::Name { name, alias } if alias.as_deref().unwrap_or(name) == head => {
1087                Some(format!("{}.{name}", imp.module))
1088            }
1089            ImportItem::Module { alias: Some(a) } if a == head => Some(imp.module.clone()),
1090            _ => None,
1091        })
1092    })?;
1093    let mut parts = vec![base];
1094    parts.extend(rest.iter().cloned());
1095    Some(parts.join("."))
1096}
1097
1098/// Identifier casing: `snake_case`, `camelCase`, `PascalCase`, `SCREAMING_SNAKE`.
1099pub fn casing(name: &str) -> &'static str {
1100    let core = name.trim_matches('_');
1101    if core.is_empty() {
1102        return "other";
1103    }
1104    let up = core.chars().any(|c| c.is_ascii_uppercase());
1105    let lo = core.chars().any(|c| c.is_ascii_lowercase());
1106    if !lo && up {
1107        return "SCREAMING_SNAKE";
1108    }
1109    if core.contains('_') && lo {
1110        return if core.starts_with(|c: char| c.is_ascii_uppercase()) {
1111            "other"
1112        } else {
1113            "snake_case"
1114        };
1115    }
1116    let f = core.chars().next().unwrap_or('a');
1117    if f.is_ascii_uppercase() && lo {
1118        return "PascalCase";
1119    }
1120    if f.is_ascii_lowercase() && up {
1121        return "camelCase";
1122    }
1123    "snake_case"
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129
1130    #[test]
1131    fn casing_classes() {
1132        assert_eq!(casing("get_user"), "snake_case");
1133        assert_eq!(casing("getUser"), "camelCase");
1134        assert_eq!(casing("UserService"), "PascalCase");
1135        assert_eq!(casing("MAX_SIZE"), "SCREAMING_SNAKE");
1136        assert_eq!(casing("_private_helper"), "snake_case");
1137    }
1138
1139    #[test]
1140    fn org_baseline_uses_generic_rules() {
1141        let c = |repo: &str| Convention {
1142            origin: Origin::Measured,
1143            repo: repo.into(),
1144            dimension: "naming".into(),
1145            key: "naming:functions:snake_case".into(),
1146            rule: format!("Functions are snake_case (10 of {repo})"),
1147            generic: "Functions are snake_case".into(),
1148            support: 10,
1149            total: 10,
1150            unit: String::new(),
1151            repos: Vec::new(),
1152            exemplars: vec![format!("{repo}/a.py:1")],
1153            counterexamples: Vec::new(),
1154        };
1155        let org = org_baseline(&[c("a"), c("b")]);
1156        assert_eq!(org.len(), 1);
1157        assert_eq!(org[0].rule, "Functions are snake_case");
1158        assert_eq!(org[0].evidence().as_deref(), Some("Shared by a and b."));
1159        assert!(!org[0].few(), "a baseline is never marked as few examples");
1160    }
1161
1162    #[test]
1163    fn org_layout_rule_keeps_an_example_directory() {
1164        let c = |repo: &str, generic: &str| Convention {
1165            origin: Origin::Measured,
1166            repo: repo.into(),
1167            dimension: "layout".into(),
1168            key: "layout:http-route-handlers".into(),
1169            rule: String::new(),
1170            generic: generic.into(),
1171            support: 3,
1172            total: 3,
1173            unit: String::new(),
1174            repos: Vec::new(),
1175            exemplars: Vec::new(),
1176            counterexamples: Vec::new(),
1177        };
1178        let root = "Put HTTP route handlers in one directory per service";
1179        let dir = "Put HTTP route handlers in one directory per service (e.g. `…/orders/`)";
1180        let org = org_baseline(&[c("inventory", root), c("orders", dir)]);
1181        assert_eq!(org[0].rule, dir);
1182    }
1183
1184    #[test]
1185    fn strength_words_and_evidence() {
1186        let mut c = Convention {
1187            origin: Origin::Measured,
1188            repo: "svc".into(),
1189            dimension: "signatures".into(),
1190            key: "signatures:params:typed".into(),
1191            rule: "Type-annotate parameters".into(),
1192            generic: "Type-annotate parameters".into(),
1193            support: 412,
1194            total: 530,
1195            unit: "functions with parameters".into(),
1196            repos: Vec::new(),
1197            exemplars: Vec::new(),
1198            counterexamples: Vec::new(),
1199        };
1200        assert_eq!(c.strength(), Some("Often"));
1201        assert_eq!(
1202            c.evidence().as_deref(),
1203            Some("Followed by 412 of the 530 functions with parameters.")
1204        );
1205        c.support = 450;
1206        assert_eq!(c.strength(), Some("Usually"));
1207        (c.support, c.total) = (6, 6);
1208        assert_eq!(c.strength(), Some("Always"));
1209        assert!(c.few());
1210        assert_eq!(thousands(1689), "1,689");
1211        c.key = "models:base:`Base`".into();
1212        assert_eq!(c.strength(), None, "a base class is counted, not followed");
1213        c.total = 0;
1214        assert_eq!((c.strength(), c.evidence()), (None, None));
1215    }
1216
1217    #[test]
1218    fn manual_changes_apply_and_undo() {
1219        let measured = vec![Convention {
1220            origin: Origin::Measured,
1221            repo: "svc".into(),
1222            dimension: "errors".into(),
1223            key: "errors:root:Base".into(),
1224            rule: "Errors derive from Base — 9 of 10".into(),
1225            generic: "Errors derive from Base".into(),
1226            support: 9,
1227            total: 10,
1228            unit: String::new(),
1229            repos: Vec::new(),
1230            exemplars: vec!["e.py:1".into()],
1231            counterexamples: Vec::new(),
1232        }];
1233        let id = measured[0].id();
1234        let added = Manual::new("svc", "Libraries", "Use the shared HTTP client", "t");
1235        let mut manual = vec![added.clone()];
1236
1237        let edit = change(
1238            &apply(measured.clone(), &manual),
1239            &manual,
1240            &id,
1241            Change::Edit("Errors derive from Base".into()),
1242            "t",
1243        )
1244        .unwrap();
1245        let Write::Put(edit) = edit else { panic!() };
1246        assert_eq!(edit.replaces.as_deref(), Some("errors:root:Base"));
1247        manual.push(edit);
1248        let shown = apply(measured.clone(), &manual);
1249        assert_eq!(shown.len(), 2);
1250        assert_eq!(
1251            (shown[0].origin, shown[0].rule.as_str(), shown[0].support),
1252            (Origin::Edited, "Errors derive from Base", 9)
1253        );
1254        assert_eq!(
1255            (shown[1].origin, shown[1].dimension.as_str(), shown[1].id()),
1256            (Origin::Manual, "libraries", added.id.clone())
1257        );
1258
1259        let Write::Put(hide) = change(&shown, &manual, &id, Change::Hide, "t").unwrap() else {
1260            panic!()
1261        };
1262        assert!(hide.hidden);
1263        manual.retain(|m| m.id != id);
1264        manual.push(hide);
1265        assert_eq!(apply(measured.clone(), &manual).len(), 1);
1266
1267        let all = apply(measured.clone(), &manual);
1268        assert_eq!(
1269            change(&all, &manual, &id, Change::Restore, "t").unwrap(),
1270            Write::Delete(id.clone())
1271        );
1272        assert_eq!(
1273            change(&all, &manual, &added.id, Change::Hide, "t").unwrap(),
1274            Write::Delete(added.id.clone())
1275        );
1276        assert!(change(&all, &manual, &added.id, Change::Restore, "t").is_err());
1277    }
1278}