Skip to main content

comindly_core/
styleguide.rs

1//! Evidence for the multi-pass LLM style-guide synthesis.
2//!
3//! A single prompt produces a shallow guide. Instead we split the guide into ~10 dimensions and
4//! hand each its OWN tailored evidence (real signatures for the parameters section, the biggest
5//! classes for design patterns, per-library call-sites for library usage, …), then run one focused
6//! LLM call per dimension and stitch the results. The valuable, non-surface conventions live in the
7//! code, so every section is grounded in real excerpts, dependency facts, or measured stats.
8//! `COMINDLY_DEBUG_EVIDENCE=1` prints each section's evidence to stderr.
9
10use std::cell::RefCell;
11use std::collections::{BTreeMap, BTreeSet, HashMap};
12use std::fmt::Write as _;
13use std::path::{Path, PathBuf};
14
15use crate::model::{Edge, EdgeKind, Symbol, SymbolKind};
16
17/// One dimension of the guide: a heading, guidance for the LLM, and the evidence it reasons over.
18pub struct Section {
19    pub title: String,
20    pub guidance: String,
21    pub evidence: String,
22}
23
24const EXCERPT_LINES: usize = 22;
25const SECTION_BUDGET: usize = 9000; // per-section evidence cap (chars)
26
27/// An evidence builder for one section.
28type EvFn = fn(&Ctx) -> String;
29
30/// Each guide section's title and what the LLM is asked to cover in it; prompts may replace the latter.
31pub const SECTIONS: [(&str, &str); 10] = [
32    (
33        "Stack & dependencies",
34        "Name each key dependency and, in one line, what the repo uses it for. Note the language/runtime + versions. Group first-party (their own) modules separately. No generic filler.",
35    ),
36    (
37        "Project layout & architecture",
38        "Describe the directory responsibilities, the layering (e.g. routes→services→repositories→models), where entry points live, and how modules are wired. Cite real dirs/files. If layering isn't evident, say so.",
39    ),
40    (
41        "Naming & formatting",
42        "State the casing per symbol kind (with the measured ratio), private-name convention, file/module naming, line length / quote style / indent from the tooling config. Give a real conforming example. Bans name the replacement.",
43    ),
44    (
45        "Function signatures & parameters",
46        "From the real signatures: how are parameters typed, ordered, defaulted? keyword-only args, *args/**kwargs, return-type annotations, self/cls, async. Document the dominant signature shape with a real example; flag what to avoid.",
47    ),
48    (
49        "Types & data modeling",
50        "How is data modeled and validated — pydantic BaseModel / dataclasses / TypedDict / TS interfaces / zod? Where do models live, how are they named, how is validation done at boundaries? Quote a real model.",
51    ),
52    (
53        "Design patterns & idioms",
54        "Identify the recurring architectural patterns from the largest/most-central classes and decorated defs: base classes, dependency injection, factories, context managers, decorators, service/repository objects, the repo's own wrappers. Quote a real one and say when to follow it.",
55    ),
56    (
57        "Library & infra usage",
58        "For each major dependency shown, document the ONE idiomatic way this repo uses it, quoting a real call-site (file path). Prefer the repo's own wrapper over the raw library where one exists (e.g. AWS via a wrapper, not raw boto3). Include DB/HTTP access patterns.",
59    ),
60    (
61        "Error handling, logging & configuration",
62        "How are errors raised/caught (custom exceptions? broad except? Result types?), how is logging done (which logger, structured?), and how is configuration/secrets accessed (never hardcoded)? Quote real call-sites.",
63    ),
64    (
65        "Testing",
66        "Framework, test file location/naming, fixtures, how external I/O is mocked, assertion style. Quote a real test. If tests are sparse, say so.",
67    ),
68    (
69        "Docstrings & comments",
70        "Dominant docstring style (Google/NumPy/reST/JSDoc) and when docs are expected (public API?), comment density/placement. Quote a real docstring. Don't demand docs the repo doesn't write.",
71    ),
72];
73
74const EVIDENCE: [EvFn; 10] = [
75    ev_stack,
76    ev_layout,
77    ev_naming,
78    ev_signatures,
79    ev_types,
80    ev_patterns,
81    ev_library,
82    ev_errlog,
83    ev_testing,
84    ev_docstrings,
85];
86
87/// Build every guide section for one repo. Reads source lazily (cached).
88pub fn build_sections(
89    root: &Path,
90    symbols: &[&Symbol],
91    edges: &[Edge],
92    conventions: &[crate::conventions::Convention],
93) -> Vec<Section> {
94    let ctx = Ctx::new(root, symbols, edges);
95    SECTIONS
96        .iter()
97        .zip(EVIDENCE)
98        .filter_map(|((title, guidance), f)| {
99            let mut evidence = measured(&ctx, title, conventions);
100            evidence.push_str(&f(&ctx));
101            if evidence.trim().is_empty() {
102                return None;
103            }
104            if evidence.len() > SECTION_BUDGET {
105                evidence.truncate(SECTION_BUDGET);
106                evidence.push_str("\n…(truncated)");
107            }
108            Some(Section {
109                title: title.to_string(),
110                guidance: guidance.to_string(),
111                evidence,
112            })
113        })
114        .collect()
115}
116
117/// Measured conventions for a section's dimensions, with excerpts at their exemplar locations.
118fn measured(ctx: &Ctx, title: &str, conventions: &[crate::conventions::Convention]) -> String {
119    let dims: &[&str] = match title {
120        "Stack & dependencies" => &["libraries"],
121        "Project layout & architecture" => &["layout"],
122        "Naming & formatting" => &["naming"],
123        "Function signatures & parameters" => &["signatures"],
124        "Types & data modeling" | "Design patterns & idioms" => &["models"],
125        "Library & infra usage" => &["libraries", "config"],
126        "Error handling, logging & configuration" => &["errors", "logging", "config"],
127        "Testing" => &["testing"],
128        _ => &[],
129    };
130    let relevant: Vec<_> = conventions
131        .iter()
132        .filter(|c| dims.contains(&c.dimension.as_str()))
133        .collect();
134    if relevant.is_empty() {
135        return String::new();
136    }
137    let mut o =
138        String::from("Measured conventions (authoritative: cite them, never contradict them):\n");
139    for c in &relevant {
140        match c.evidence() {
141            Some(e) => writeln!(o, "- {} ({e})", c.rule),
142            None => writeln!(o, "- {}", c.rule),
143        }
144        .ok();
145        if !c.counterexamples.is_empty() {
146            let _ = writeln!(o, "  deviations: {}", c.counterexamples.join(", "));
147        }
148    }
149    for c in relevant.iter().take(4) {
150        for ex in c.exemplars.iter().take(1) {
151            let Some((file, line)) = ex.rsplit_once(':') else {
152                continue;
153            };
154            let start = line.parse::<usize>().unwrap_or(1);
155            if let Some(body) = ctx.excerpt(file, start, 14) {
156                block(&mut o, &format!("{ex} — exemplar of: {}", c.generic), &body);
157            }
158        }
159    }
160    o.push('\n');
161    o
162}
163
164// ---- shared repo context -------------------------------------------------------------------
165struct Ctx<'a> {
166    root: PathBuf,
167    symbols: &'a [&'a Symbol],
168    files: Vec<String>,
169    cache: RefCell<HashMap<String, Option<String>>>,
170    hist: Vec<(String, Vec<String>)>, // module -> importing files, ranked
171}
172
173impl<'a> Ctx<'a> {
174    fn new(root: &Path, symbols: &'a [&'a Symbol], edges: &'a [Edge]) -> Self {
175        let mut files: Vec<String> = symbols
176            .iter()
177            .filter(|s| matches!(s.kind, SymbolKind::File))
178            .map(|s| s.file_path.clone())
179            .collect();
180        files.sort();
181        files.dedup();
182        let id2file: HashMap<String, String> = symbols
183            .iter()
184            .map(|s| (s.id.render(), s.file_path.clone()))
185            .collect();
186        let mut mods: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
187        for e in edges.iter().filter(|e| e.kind == EdgeKind::Imports) {
188            let d = &e.dst.descriptor;
189            let p: Vec<&str> = d.split('/').collect();
190            let m = if d.starts_with('@') && p.len() >= 2 {
191                format!("{}/{}", p[0], p[1])
192            } else {
193                p.first().unwrap_or(&"").to_string()
194            };
195            if m.is_empty() {
196                continue;
197            }
198            let f = id2file.get(&e.src.render()).cloned().unwrap_or_default();
199            if !f.is_empty() {
200                mods.entry(m).or_default().insert(f);
201            }
202        }
203        let mut hist: Vec<(String, Vec<String>)> = mods
204            .into_iter()
205            .map(|(m, f)| (m, f.into_iter().collect()))
206            .collect();
207        hist.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(&b.0)));
208        Ctx {
209            root: root.to_path_buf(),
210            symbols,
211            files,
212            cache: RefCell::new(HashMap::new()),
213            hist,
214        }
215    }
216
217    fn read(&self, file: &str) -> Option<String> {
218        if let Some(v) = self.cache.borrow().get(file) {
219            return v.clone();
220        }
221        let v = std::fs::read_to_string(self.root.join(file)).ok();
222        self.cache.borrow_mut().insert(file.to_string(), v.clone());
223        v
224    }
225
226    fn excerpt(&self, file: &str, start: usize, n: usize) -> Option<String> {
227        let src = self.read(file)?;
228        let lines: Vec<&str> = src.lines().skip(start.saturating_sub(1)).take(n).collect();
229        if lines.iter().all(|l| l.trim().is_empty()) {
230            return None;
231        }
232        Some(lines.join("\n"))
233    }
234
235    fn syms(&self, kinds: &[SymbolKind]) -> Vec<&Symbol> {
236        self.symbols
237            .iter()
238            .filter(|s| kinds.contains(&s.kind))
239            .copied()
240            .collect()
241    }
242
243    /// Find files whose content matches a predicate (bounded scan).
244    fn files_matching(&self, pred: impl Fn(&str) -> bool, limit: usize) -> Vec<String> {
245        let mut out = Vec::new();
246        for f in &self.files {
247            if out.len() >= limit {
248                break;
249            }
250            if let Some(src) = self.read(f) {
251                if pred(&src) {
252                    out.push(f.clone());
253                }
254            }
255        }
256        out
257    }
258}
259
260fn block(o: &mut String, file: &str, body: &str) {
261    let _ = writeln!(o, "\n### {file}\n```\n{}\n```", body.trim_end());
262}
263
264// ---- casing (shared) -----------------------------------------------------------------------
265fn casing(name: &str) -> &'static str {
266    let core = name.trim_matches('_');
267    if core.is_empty() {
268        return "other";
269    }
270    let up = core.chars().any(|c| c.is_ascii_uppercase());
271    let lo = core.chars().any(|c| c.is_ascii_lowercase());
272    if !lo && up {
273        return "SCREAMING_SNAKE";
274    }
275    if core.contains('_') && lo {
276        return "snake_case";
277    }
278    let f = core.chars().next().unwrap();
279    if f.is_ascii_uppercase() && lo {
280        return "PascalCase";
281    }
282    if f.is_ascii_lowercase() && up {
283        return "camelCase";
284    }
285    "snake_case"
286}
287
288// ---- section evidence builders -------------------------------------------------------------
289fn ev_stack(ctx: &Ctx) -> String {
290    let mut o = String::new();
291    let read = |n: &str| std::fs::read_to_string(ctx.root.join(n)).ok();
292    let mut deps: Vec<String> = Vec::new();
293    if let Some(t) = read("pyproject.toml") {
294        if let Ok(v) = t.parse::<toml::Table>() {
295            if let Some(a) = v
296                .get("project")
297                .and_then(|p| p.get("dependencies"))
298                .and_then(|d| d.as_array())
299            {
300                deps.extend(a.iter().filter_map(|d| d.as_str().map(str::to_string)));
301            }
302        }
303    }
304    if let Some(t) = read("package.json") {
305        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) {
306            if let Some(ob) = v.get("dependencies").and_then(|d| d.as_object()) {
307                deps.extend(
308                    ob.iter()
309                        .map(|(k, ver)| format!("{k} {}", ver.as_str().unwrap_or(""))),
310                );
311            }
312        }
313    }
314    if !deps.is_empty() {
315        let _ = writeln!(o, "Declared dependencies:");
316        for d in deps.iter().take(40) {
317            let _ = writeln!(o, "- {}", d.trim());
318        }
319    }
320    if !ctx.hist.is_empty() {
321        let _ = writeln!(
322            o,
323            "\nMost-imported modules (module → #files; some are first-party):"
324        );
325        for (m, f) in ctx.hist.iter().take(20) {
326            let _ = writeln!(o, "- {m}  ({} files)", f.len());
327        }
328    }
329    o
330}
331
332fn ev_layout(ctx: &Ctx) -> String {
333    let mut o = String::new();
334    let mut dirs: BTreeMap<String, usize> = BTreeMap::new();
335    for f in &ctx.files {
336        let d = match f.rsplit_once('/') {
337            Some((dir, _)) => format!("{dir}/"),
338            None => "(root)".into(),
339        };
340        *dirs.entry(d).or_default() += 1;
341    }
342    let _ = writeln!(o, "Directories (path → file count):");
343    for (d, n) in dirs.iter().take(40) {
344        let _ = writeln!(o, "- {d}  {n}");
345    }
346    // entry points + a couple central files
347    let hints = [
348        "main.py",
349        "app.py",
350        "cli.py",
351        "index.ts",
352        "index.tsx",
353        "__main__.py",
354        "server.py",
355    ];
356    let mut shown = 0;
357    let _ = writeln!(o, "\nEntry-point / central files:");
358    for f in &ctx.files {
359        if hints.iter().any(|h| f.ends_with(h)) {
360            if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
361                block(&mut o, f, &ex);
362                shown += 1;
363            }
364        }
365        if shown >= 4 {
366            break;
367        }
368    }
369    o
370}
371
372fn ev_naming(ctx: &Ctx) -> String {
373    let mut o = String::new();
374    let dom = |kinds: &[SymbolKind]| {
375        let mut h: BTreeMap<&str, usize> = BTreeMap::new();
376        let ss = ctx.syms(kinds);
377        for s in &ss {
378            *h.entry(casing(&s.name)).or_default() += 1;
379        }
380        let total = ss.len();
381        let names: Vec<String> = ss.iter().take(10).map(|s| s.name.clone()).collect();
382        (
383            h.into_iter()
384                .max_by_key(|(_, c)| *c)
385                .map(|(k, c)| (k.to_string(), c)),
386            total,
387            names,
388        )
389    };
390    for (label, kinds) in [
391        (
392            "functions/methods",
393            &[SymbolKind::Function, SymbolKind::Method][..],
394        ),
395        (
396            "classes/interfaces",
397            &[SymbolKind::Class, SymbolKind::Interface][..],
398        ),
399    ] {
400        let (d, total, names) = dom(kinds);
401        if let Some((k, c)) = d {
402            let _ = writeln!(
403                o,
404                "{label}: {k} {c}/{total}. examples: {}",
405                names.join(", ")
406            );
407        }
408    }
409    // formatting facts
410    if let Ok(t) = std::fs::read_to_string(ctx.root.join(".editorconfig")) {
411        for l in t.lines() {
412            let l = l.trim();
413            if ["indent_style", "indent_size", "max_line_length"]
414                .iter()
415                .any(|k| l.starts_with(k))
416            {
417                let _ = writeln!(o, "editorconfig: {l}");
418            }
419        }
420    }
421    if let Ok(t) = std::fs::read_to_string(ctx.root.join("pyproject.toml")) {
422        for key in ["line-length", "quote-style"] {
423            if let Some(line) = t.lines().find(|l| l.trim_start().starts_with(key)) {
424                let _ = writeln!(o, "ruff/black: {}", line.trim());
425            }
426        }
427    }
428    o
429}
430
431fn ev_signatures(ctx: &Ctx) -> String {
432    let mut o = String::from("Real signatures (sampled):\n");
433    let mut n = 0;
434    for s in ctx.syms(&[SymbolKind::Function, SymbolKind::Method]) {
435        if let Some(sig) = &s.signature {
436            let sig = sig.trim();
437            if sig.len() > 6 {
438                let _ = writeln!(o, "- {}  ({}:{})", sig, s.file_path, s.range.start.line);
439                n += 1;
440            }
441        }
442        if n >= 30 {
443            break;
444        }
445    }
446    if n == 0 {
447        return String::new();
448    }
449    o
450}
451
452fn ev_types(ctx: &Ctx) -> String {
453    let files = ctx.files_matching(
454        |s| {
455            s.contains("BaseModel")
456                || s.contains("@dataclass")
457                || s.contains("TypedDict")
458                || s.contains("z.object(")
459                || s.contains("interface ")
460                || s.contains("z.infer")
461        },
462        4,
463    );
464    if files.is_empty() {
465        return String::new();
466    }
467    let mut o = String::from("Model / type definitions:\n");
468    for f in &files {
469        if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
470            block(&mut o, f, &ex);
471        }
472    }
473    o
474}
475
476fn ev_patterns(ctx: &Ctx) -> String {
477    let mut o = String::new();
478    // largest classes (by line span) — usually base/service/controller classes
479    let mut classes = ctx.syms(&[SymbolKind::Class]);
480    classes.sort_by_key(|s| std::cmp::Reverse(s.range.end.line.saturating_sub(s.range.start.line)));
481    if !classes.is_empty() {
482        let _ = writeln!(o, "Largest classes (likely base/service/controller):");
483    }
484    for s in classes.iter().take(4) {
485        if let Some(ex) = ctx.excerpt(&s.file_path, s.range.start.line as usize, EXCERPT_LINES) {
486            block(
487                &mut o,
488                &format!("{} ({}:{})", s.name, s.file_path, s.range.start.line),
489                &ex,
490            );
491        }
492    }
493    // decorated defs (DI / factories / context managers)
494    let deco = ctx.files_matching(
495        |src| {
496            src.contains("@contextmanager")
497                || src.contains("@property")
498                || src.contains("@staticmethod")
499                || src.contains("@classmethod")
500                || src.contains("Depends(")
501                || src.contains("@inject")
502        },
503        2,
504    );
505    for f in &deco {
506        if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
507            block(&mut o, f, &ex);
508        }
509    }
510    o
511}
512
513fn ev_library(ctx: &Ctx) -> String {
514    let mut o = String::from("Representative call-sites per major dependency:\n");
515    let mut shown = 0;
516    for (m, files) in ctx.hist.iter().take(10) {
517        if let Some(f) = files.first() {
518            if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
519                block(&mut o, &format!("{f}  (uses `{m}`)"), &ex);
520                shown += 1;
521            }
522        }
523        if shown >= 7 {
524            break;
525        }
526    }
527    if shown == 0 {
528        return String::new();
529    }
530    o
531}
532
533fn ev_errlog(ctx: &Ctx) -> String {
534    let files = ctx.files_matching(
535        |s| {
536            s.contains("except ")
537                || s.contains("logger")
538                || s.contains("getLogger")
539                || s.contains("raise ")
540                || s.contains("get_config")
541                || s.contains("Settings(")
542        },
543        4,
544    );
545    if files.is_empty() {
546        return String::new();
547    }
548    let mut o = String::from("Error / logging / config call-sites:\n");
549    for f in &files {
550        if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
551            block(&mut o, f, &ex);
552        }
553    }
554    o
555}
556
557fn ev_testing(ctx: &Ctx) -> String {
558    let files: Vec<String> = ctx
559        .files
560        .iter()
561        .filter(|f| {
562            let l = f.to_lowercase();
563            l.contains("test_")
564                || l.ends_with("_test.py")
565                || l.ends_with(".test.ts")
566                || l.ends_with(".spec.ts")
567                || l.contains("/tests/")
568                || l.ends_with("conftest.py")
569        })
570        .take(3)
571        .cloned()
572        .collect();
573    if files.is_empty() {
574        return String::new();
575    }
576    let mut o = String::from("Test files:\n");
577    for f in &files {
578        if let Some(ex) = ctx.excerpt(f, 1, EXCERPT_LINES) {
579            block(&mut o, f, &ex);
580        }
581    }
582    o
583}
584
585fn ev_docstrings(ctx: &Ctx) -> String {
586    let (mut g, mut np, mut rest, mut jsdoc) = (0, 0, 0, 0);
587    for f in ctx.files.iter().take(400) {
588        if let Some(s) = ctx.read(f) {
589            if s.contains("Args:") || s.contains("Returns:") {
590                g += 1;
591            }
592            if s.contains("Parameters\n") {
593                np += 1;
594            }
595            if s.contains(":param ") {
596                rest += 1;
597            }
598            if s.contains("@param") || s.contains("/**") {
599                jsdoc += 1;
600            }
601        }
602    }
603    let style = [
604        ("Google", g),
605        ("NumPy", np),
606        ("reST", rest),
607        ("JSDoc", jsdoc),
608    ]
609    .into_iter()
610    .max_by_key(|(_, n)| *n)
611    .filter(|(_, n)| *n > 0)
612    .map(|(k, _)| k);
613    let style = match style {
614        Some(s) => s,
615        None => return String::new(),
616    };
617    let mut o = format!("Dominant docstring style: {style}.\nExamples of documented code:\n");
618    // a couple of files that actually contain the style marker
619    let marker = match style {
620        "Google" => "Returns:",
621        "NumPy" => "Parameters\n",
622        "reST" => ":param ",
623        _ => "/**",
624    };
625    for f in ctx.files_matching(|s| s.contains(marker), 2) {
626        if let Some(ex) = ctx.excerpt(&f, 1, EXCERPT_LINES + 8) {
627            block(&mut o, &f, &ex);
628        }
629    }
630    o
631}