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