1use 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
17pub const ORG: &str = "*";
19
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct Convention {
22 pub repo: String,
24 pub dimension: String,
27 pub key: String,
29 pub rule: String,
30 pub generic: String,
32 pub support: usize,
33 pub total: usize,
34 pub exemplars: Vec<String>,
36 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 Edited,
49 Manual,
51}
52
53const MANUAL: &str = "manual:";
54
55impl Convention {
56 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct Manual {
73 pub id: String,
74 pub repo: String,
76 pub dimension: String,
77 pub rule: String,
79 #[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#[derive(Debug, PartialEq)]
119pub enum Write {
120 Put(Manual),
121 Delete(String),
122}
123
124pub enum Change {
125 Edit(String),
126 Hide,
127 Restore,
129}
130
131pub 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
175pub 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
212pub 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
273const 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
337fn 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 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 dependents: HashMap<String, usize>,
382 out_edges: HashMap<(String, EdgeKind), Vec<String>>,
384 modules: HashSet<&'a str>,
386 wraps: HashMap<&'a str, HashSet<String>>,
388 typed: HashSet<&'a str>,
390 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 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 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 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 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 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 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 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 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
1027fn 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
1064fn 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
1114fn 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
1125fn 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
1136fn 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
1160fn 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
1177pub 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}