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};
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#[derive(Debug, PartialEq)]
24pub enum Write {
25 Put(Manual),
26 Delete(String),
27}
28
29pub enum Change {
30 Edit(String),
31 Hide,
32 Restore,
34}
35
36pub 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
80pub 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
119pub 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
180const 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
244fn 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 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 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 dependents: HashMap<String, usize>,
291 out_edges: HashMap<(String, EdgeKind), Vec<String>>,
293 modules: HashSet<&'a str>,
295 wraps: HashMap<&'a str, HashSet<String>>,
297 typed: HashSet<&'a str>,
299 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 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 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 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 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 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 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 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 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
953fn 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
992fn 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
1035fn 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
1046fn 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
1057fn 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
1081fn 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
1098pub 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}