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 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
91fn 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
134struct 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>)>, }
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 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
234fn 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
258fn 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 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 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 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 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 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}