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