1use 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
17pub 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; type EvFn = fn(&Ctx) -> String;
29
30pub 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
87pub 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
117fn 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
164struct 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>)>, }
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 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
264fn 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
288fn 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 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 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 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 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 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}