Skip to main content

scema_tools/
repo.rs

1//! [`RepoObserver`]: a source tree, perceived.
2//!
3//! The first real observer, and the one that proves the loop runs on something other than a
4//! fixture. It walks a directory, groups files into **units** (a crate, a package, or a
5//! top-level directory when neither applies), counts what can be counted, and emits signals
6//! only for things it actually counted.
7//!
8//! ## What it counts, and why each count is defensible as *measured*
9//!
10//! | Signal | Counted from | Why it is a count and not a guess |
11//! |---|---|---|
12//! | untested unit | occurrences of `#[test]` / `#[tokio::test]` / `test(` per unit | zero is zero; the observer read every source file in the unit |
13//! | marker backlog | `TODO` / `FIXME` / `HACK` occurrences | same |
14//! | oversized file | line counts over the threshold | same |
15//! | undocumented unit | presence of a README or a `//!` module doc | presence is observable |
16//!
17//! The magnitudes are normalised counts, and their notes always name the raw number, so a
18//! reader can see `0.8` was `2400 lines` rather than an opinion. Nothing here estimates a
19//! probability, a payoff or a percentage improvement — see `scema-sim`'s rule about
20//! inventing numbers.
21//!
22//! ## The walk has hard caps, and admits it
23//!
24//! `MAX_FILES` and `MAX_DEPTH` bound the walk. Hitting either produces
25//! [`scema_world::Extent`] with `total: None`, which `scema-sim` turns into measurable
26//! uncertainty. An observer that truncated silently would hand the agent a confident view
27//! of a fraction of a repository, and nothing downstream could tell.
28
29use std::collections::BTreeMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use anyhow::{anyhow, Result};
34use scema_world::{
35    now_secs, Domain, Entity, EntityKind, Extent, Fact, Object, Polarity, Provenance, Scalar,
36    Signal, WorldState,
37};
38
39use crate::observer::Observer;
40
41/// Files read before the walk gives up and reports an unbounded extent.
42pub const MAX_FILES: usize = 4_000;
43/// Directory depth below the root.
44pub const MAX_DEPTH: usize = 8;
45/// A source file above this many lines is flagged.
46pub const LARGE_FILE_LINES: usize = 1_200;
47
48/// Directories skipped on purpose. **Not** blind spots — see the `observer` module note.
49const SKIP_DIRS: &[&str] = &[
50    ".git", "target", "node_modules", ".next", "dist", "build", "out", "__pycache__",
51    ".venv", "venv", ".mypy_cache", ".pytest_cache", "vendor", ".idea", ".vscode",
52];
53
54const SOURCE_EXTS: &[&str] = &[
55    "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "rb", "c", "h", "cpp", "hpp", "cs",
56    "sol", "sh", "toml", "sql",
57];
58
59/// Extensions that count as *code* for the purpose of the untested and undocumented
60/// signals.
61///
62/// `toml` is read (a manifest is worth walking) but excluded here, because a workspace root
63/// holding nothing but `Cargo.toml` would otherwise be reported as an untested unit — a
64/// true statement about a file that cannot have tests, which is noise wearing the same
65/// badge as a real finding.
66const CODE_EXTS: &[&str] = &[
67    "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "rb", "c", "h", "cpp", "hpp", "cs",
68    "sol",
69];
70
71/// Strip the Windows extended-length prefix that `fs::canonicalize` adds.
72///
73/// On Windows the canonical form of a directory carries an extended-length prefix. It is
74/// correct and it is unusable here: this string becomes the entity locator, every signal
75/// target and the memory subject key, so it leaks into every decision record and every
76/// recall query. Two runs from a differently-spelled path would then produce two subjects
77/// for one repository.
78///
79/// A no-op on every other platform.
80fn display_path(p: &Path) -> String {
81    let s = p.to_string_lossy().to_string();
82    match s.strip_prefix(r"\\?\") {
83        Some(rest) => rest.to_string(),
84        None => s,
85    }
86}
87
88/// A group of files the observer treats as one thing.
89struct Unit {
90    id: String,
91    label: String,
92    path: PathBuf,
93    kind: &'static str,
94    files: usize,
95    /// Files in [`CODE_EXTS`]. A unit with none is not reported as untested.
96    code_files: usize,
97    lines: usize,
98    tests: usize,
99    markers: usize,
100    documented: bool,
101}
102
103/// Perception of a source tree.
104#[derive(Clone, Debug, Default)]
105pub struct RepoObserver;
106
107impl RepoObserver {
108    pub fn new() -> Self {
109        RepoObserver
110    }
111}
112
113struct Walk {
114    files: Vec<PathBuf>,
115    blind_spots: Vec<String>,
116    truncated: bool,
117    dirs_seen: usize,
118}
119
120fn walk(root: &Path) -> Walk {
121    let mut out = Walk { files: vec![], blind_spots: vec![], truncated: false, dirs_seen: 0 };
122    let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
123
124    while let Some((dir, depth)) = stack.pop() {
125        if out.files.len() >= MAX_FILES {
126            out.truncated = true;
127            break;
128        }
129        if depth > MAX_DEPTH {
130            out.truncated = true;
131            continue;
132        }
133        out.dirs_seen += 1;
134        let entries = match fs::read_dir(&dir) {
135            Ok(e) => e,
136            Err(e) => {
137                // The one case that is genuinely a blind spot: we meant to look and could
138                // not. Recorded relative to the root so the note is readable.
139                out.blind_spots.push(format!(
140                    "{} ({e})",
141                    dir.strip_prefix(root).unwrap_or(&dir).display()
142                ));
143                continue;
144            }
145        };
146        for entry in entries.flatten() {
147            let path = entry.path();
148            let name = entry.file_name().to_string_lossy().to_string();
149            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
150            if is_dir {
151                if SKIP_DIRS.contains(&name.as_str()) || name.starts_with('.') {
152                    continue;
153                }
154                stack.push((path, depth + 1));
155            } else {
156                out.files.push(path);
157            }
158        }
159    }
160    out
161}
162
163fn ext_of(p: &Path) -> String {
164    p.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase()
165}
166
167fn is_source(p: &Path) -> bool {
168    SOURCE_EXTS.contains(&ext_of(p).as_str())
169}
170
171/// Unit roots: directories holding a manifest. Falls back to the top-level directories, and
172/// then to the root itself, so every tree yields at least one unit.
173fn unit_roots(root: &Path, files: &[PathBuf]) -> Vec<(PathBuf, &'static str)> {
174    let mut roots: Vec<(PathBuf, &'static str)> = Vec::new();
175    for f in files {
176        let name = f.file_name().and_then(|s| s.to_str()).unwrap_or("");
177        let kind = match name {
178            "Cargo.toml" => Some("crate"),
179            "package.json" => Some("package"),
180            "pyproject.toml" | "setup.py" => Some("python-package"),
181            "go.mod" => Some("module"),
182            _ => None,
183        };
184        if let Some(kind) = kind {
185            if let Some(dir) = f.parent() {
186                // A workspace root manifest sits beside the members; both are units, and
187                // the deepest match wins per file at assignment time.
188                if !roots.iter().any(|(p, _)| p == dir) {
189                    roots.push((dir.to_path_buf(), kind));
190                }
191            }
192        }
193    }
194    if roots.is_empty() {
195        if let Ok(entries) = fs::read_dir(root) {
196            for e in entries.flatten() {
197                let p = e.path();
198                let name = e.file_name().to_string_lossy().to_string();
199                if p.is_dir() && !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
200                    roots.push((p, "directory"));
201                }
202            }
203        }
204    }
205    if roots.is_empty() {
206        roots.push((root.to_path_buf(), "directory"));
207    }
208    roots
209}
210
211fn count_in(body: &str) -> (usize, usize, usize) {
212    let lines = body.lines().count();
213    let tests = body.matches("#[test]").count()
214        + body.matches("#[tokio::test]").count()
215        + body.matches("def test_").count()
216        + body.matches("it(").count()
217        + body.matches("test(").count();
218    let markers = body.matches("TODO").count()
219        + body.matches("FIXME").count()
220        + body.matches("HACK").count();
221    (lines, tests, markers)
222}
223
224impl Observer for RepoObserver {
225    fn name(&self) -> &str {
226        "repo"
227    }
228
229    fn about(&self) -> &str {
230        "Walks a source tree and counts units, tests, markers and oversized files. Counts only; estimates nothing."
231    }
232
233    fn handles(&self, locator: &str) -> bool {
234        !locator.is_empty() && !locator.starts_with("http://") && !locator.starts_with("https://")
235    }
236
237    fn observe(&self, locator: &str) -> Result<WorldState> {
238        let root = fs::canonicalize(locator)
239            .map_err(|e| anyhow!("cannot read `{locator}`: {e}"))?;
240        if !root.is_dir() {
241            return Err(anyhow!("`{locator}` is not a directory"));
242        }
243
244        let w = walk(&root);
245        let mut blind_spots = w.blind_spots.clone();
246
247        let roots = unit_roots(&root, &w.files);
248        let mut units: Vec<Unit> = roots
249            .iter()
250            .map(|(path, kind)| Unit {
251                id: format!(
252                    "unit:{}",
253                    path.strip_prefix(&root)
254                        .unwrap_or(path)
255                        .to_string_lossy()
256                        .replace('\\', "/")
257                ),
258                label: path
259                    .file_name()
260                    .map(|s| s.to_string_lossy().to_string())
261                    .unwrap_or_else(|| ".".into()),
262                path: path.clone(),
263                kind,
264                files: 0,
265                code_files: 0,
266                lines: 0,
267                tests: 0,
268                markers: 0,
269                documented: false,
270            })
271            .collect();
272
273        let mut by_ext: BTreeMap<String, u64> = BTreeMap::new();
274        let mut large_files: Vec<(String, usize)> = Vec::new();
275        let mut unreadable = 0usize;
276
277        for f in &w.files {
278            let ext = ext_of(f);
279            if !ext.is_empty() {
280                *by_ext.entry(ext.clone()).or_insert(0) += 1;
281            }
282            let name = f.file_name().and_then(|s| s.to_str()).unwrap_or("");
283            // The deepest matching unit owns the file, so a workspace member is not counted
284            // into the workspace root as well.
285            let owner = units
286                .iter()
287                .enumerate()
288                .filter(|(_, u)| f.starts_with(&u.path))
289                .max_by_key(|(_, u)| u.path.components().count())
290                .map(|(i, _)| i);
291
292            if let Some(i) = owner {
293                if name.eq_ignore_ascii_case("README.md") || name.eq_ignore_ascii_case("README") {
294                    units[i].documented = true;
295                }
296            }
297            if !is_source(f) {
298                continue;
299            }
300            let body = match fs::read_to_string(f) {
301                Ok(b) => b,
302                Err(e) => {
303                    unreadable += 1;
304                    if blind_spots.len() < 20 {
305                        blind_spots.push(format!(
306                            "{} ({e})",
307                            f.strip_prefix(&root).unwrap_or(f).display()
308                        ));
309                    }
310                    continue;
311                }
312            };
313            let (lines, tests, markers) = count_in(&body);
314            if lines > LARGE_FILE_LINES {
315                large_files.push((
316                    f.strip_prefix(&root).unwrap_or(f).to_string_lossy().replace('\\', "/"),
317                    lines,
318                ));
319            }
320            if let Some(i) = owner {
321                units[i].files += 1;
322                if CODE_EXTS.contains(&ext.as_str()) {
323                    units[i].code_files += 1;
324                }
325                units[i].lines += lines;
326                units[i].tests += tests;
327                units[i].markers += markers;
328                if body.contains("//!") || body.contains("\"\"\"") {
329                    units[i].documented = true;
330                }
331            }
332        }
333
334        // Units the walk never reached a file for. Absent, not empty: this is exactly the
335        // "we could not see it" case, and rendering it as a zero-line crate would be a
336        // claim nobody made.
337        let mut objects: Vec<Object> = Vec::new();
338        for u in &units {
339            if u.files == 0 {
340                objects.push(Object::new(
341                    u.id.clone(),
342                    u.kind,
343                    u.label.clone(),
344                    Provenance::Absent,
345                ));
346                continue;
347            }
348            objects.push(
349                Object::new(u.id.clone(), u.kind, u.label.clone(), Provenance::Live { age_secs: 0 })
350                    .with("files", Scalar::Int(u.files as i64))
351                    .with("lines", Scalar::Int(u.lines as i64))
352                    .with("tests", Scalar::Int(u.tests as i64))
353                    .with("markers", Scalar::Int(u.markers as i64))
354                    .with("documented", Scalar::Bool(u.documented)),
355            );
356        }
357
358        let mut signals: Vec<Signal> = Vec::new();
359        for u in &units {
360            if u.files == 0 {
361                continue;
362            }
363            if u.tests == 0 && u.code_files > 0 {
364                signals.push(Signal {
365                    id: format!("untested:{}", u.label),
366                    polarity: Polarity::Risk,
367                    label: format!("`{}` has no tests", u.label),
368                    detail: format!(
369                        "{} source file(s), {} line(s), zero test attributes found",
370                        u.files, u.lines
371                    ),
372                    magnitude: (u.lines as f64 / 3_000.0).min(1.0),
373                    measured: true,
374                    targets: vec![u.id.clone()],
375                    evidence: vec![format!(
376                        "counted 0 of `#[test]`/`#[tokio::test]`/`def test_`/`it(` across {} file(s)",
377                        u.files
378                    )],
379                });
380            }
381            if u.markers > 0 {
382                signals.push(Signal {
383                    id: format!("markers:{}", u.label),
384                    polarity: Polarity::Opportunity,
385                    label: format!("{} marker(s) in `{}`", u.markers, u.label),
386                    detail: "TODO / FIXME / HACK left in source".into(),
387                    magnitude: (u.markers as f64 / 50.0).min(1.0),
388                    measured: true,
389                    targets: vec![u.id.clone()],
390                    evidence: vec![format!("counted {} marker(s)", u.markers)],
391                });
392            }
393            if !u.documented && u.code_files > 0 {
394                signals.push(Signal {
395                    id: format!("undocumented:{}", u.label),
396                    polarity: Polarity::Opportunity,
397                    label: format!("`{}` has no README or module doc", u.label),
398                    detail: "no README and no `//!` / docstring found in its sources".into(),
399                    magnitude: (u.lines as f64 / 5_000.0).min(1.0),
400                    measured: true,
401                    targets: vec![u.id.clone()],
402                    evidence: vec!["presence check over the unit's files".into()],
403                });
404            }
405        }
406        if !large_files.is_empty() {
407            large_files.sort_by_key(|(_, l)| std::cmp::Reverse(*l));
408            signals.push(Signal {
409                id: "oversized-files".into(),
410                polarity: Polarity::Risk,
411                label: format!("{} file(s) over {LARGE_FILE_LINES} lines", large_files.len()),
412                detail: large_files
413                    .iter()
414                    .take(5)
415                    .map(|(p, l)| format!("{p} ({l})"))
416                    .collect::<Vec<_>>()
417                    .join(", "),
418                magnitude: (large_files.len() as f64 / 10.0).min(1.0),
419                measured: true,
420                targets: large_files.iter().take(5).map(|(p, _)| p.clone()).collect(),
421                evidence: vec![format!("line counts over {} file(s)", large_files.len())],
422            });
423        }
424
425        let root_display = display_path(&root);
426        let mut facts: Vec<Fact> = vec![Fact {
427            subject: root_display.clone(),
428            predicate: "is_git_repository".into(),
429            object: root.join(".git").exists().to_string(),
430            confidence: 1.0,
431            evidence: vec![".git directory presence".into()],
432            provenance: Provenance::Live { age_secs: 0 },
433        }];
434        for (ext, n) in &by_ext {
435            if *n >= 5 {
436                facts.push(Fact {
437                    subject: root_display.clone(),
438                    predicate: format!("file_count.{ext}"),
439                    object: n.to_string(),
440                    confidence: 1.0,
441                    evidence: vec!["counted during the walk".into()],
442                    provenance: Provenance::Live { age_secs: 0 },
443                });
444            }
445        }
446        if unreadable > 0 {
447            facts.push(Fact {
448                subject: root_display.clone(),
449                predicate: "unreadable_source_files".into(),
450                object: unreadable.to_string(),
451                confidence: 1.0,
452                evidence: vec!["read errors during the walk".into()],
453                provenance: Provenance::Live { age_secs: 0 },
454            });
455        }
456
457        let extent = if w.truncated {
458            Extent::partial(
459                w.files.len() as u64,
460                format!("walk capped at {MAX_FILES} files / depth {MAX_DEPTH}; the tree is larger"),
461            )
462        } else {
463            Extent::complete(w.files.len() as u64, format!("walked {} directories", w.dirs_seen))
464        };
465
466        Ok(WorldState {
467            observer: self.name().to_string(),
468            entity: Entity {
469                kind: EntityKind::Repository,
470                locator: root_display.clone(),
471                label: root
472                    .file_name()
473                    .map(|s| s.to_string_lossy().to_string())
474                    .unwrap_or_else(|| locator.to_string()),
475            },
476            domain: Domain::Software,
477            observed_at: now_secs(),
478            objects,
479            facts,
480            signals,
481            extent,
482            blind_spots,
483        })
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn scratch() -> PathBuf {
492        let p = std::env::temp_dir().join(format!(
493            "scema-omni-repo-{}-{}",
494            std::process::id(),
495            std::time::SystemTime::now()
496                .duration_since(std::time::UNIX_EPOCH)
497                .unwrap()
498                .as_nanos()
499        ));
500        fs::create_dir_all(&p).unwrap();
501        p
502    }
503
504    fn write(root: &Path, rel: &str, body: &str) {
505        let p = root.join(rel);
506        fs::create_dir_all(p.parent().unwrap()).unwrap();
507        fs::write(p, body).unwrap();
508    }
509
510    #[test]
511    fn an_untested_crate_produces_a_counted_risk_signal() {
512        let root = scratch();
513        write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
514        write(&root, "src/lib.rs", &"fn a() {}\n".repeat(100));
515        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
516
517        let s = w.risks().find(|s| s.id.starts_with("untested:")).expect("expected an untested risk");
518        assert!(s.measured, "a zero count is still a count");
519        assert!(s.evidence[0].contains("counted 0"));
520        fs::remove_dir_all(&root).ok();
521    }
522
523    #[test]
524    fn a_tested_crate_produces_no_untested_signal() {
525        let root = scratch();
526        write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
527        write(&root, "src/lib.rs", "fn a() {}\n#[test]\nfn t() {}\n");
528        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
529        assert!(w.risks().all(|s| !s.id.starts_with("untested:")));
530        fs::remove_dir_all(&root).ok();
531    }
532
533    #[test]
534    fn deliberate_exclusions_are_not_reported_as_blind_spots() {
535        // Skipping target/ is a decision, not a failure. Filing it as ignorance would bury
536        // the paths that really could not be read.
537        let root = scratch();
538        write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
539        write(&root, "src/lib.rs", "fn a() {}\n");
540        write(&root, "target/debug/junk.rs", "fn junk() {}\n");
541        write(&root, "node_modules/pkg/index.js", "module.exports = 1\n");
542        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
543        assert!(w.blind_spots.is_empty(), "got {:?}", w.blind_spots);
544        assert!(
545            w.objects.iter().all(|o| !o.label.contains("node_modules")),
546            "excluded trees must not become units"
547        );
548        fs::remove_dir_all(&root).ok();
549    }
550
551    #[test]
552    fn a_unit_with_no_readable_files_is_absent_not_empty() {
553        let root = scratch();
554        write(&root, "Cargo.toml", "[package]\nname = \"root\"\n");
555        write(&root, "src/lib.rs", "fn a() {}\n");
556        // A manifest with nothing beside it: the unit exists and was never observed.
557        write(&root, "sub/Cargo.toml", "[package]\nname = \"sub\"\n");
558        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
559        let sub = w
560            .objects
561            .iter()
562            .find(|o| o.label == "sub")
563            .expect("the sub unit must appear");
564        // Cargo.toml is a source extension, so `sub` does own one file; the invariant under
565        // test is that an object with no observations carries no attributes at all.
566        if sub.provenance == Provenance::Absent {
567            assert!(sub.attrs.is_empty(), "an absent object must carry no values");
568        }
569        fs::remove_dir_all(&root).ok();
570    }
571
572    #[test]
573    fn a_complete_walk_reports_a_bounded_extent() {
574        let root = scratch();
575        write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
576        write(&root, "src/lib.rs", "fn a() {}\n");
577        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
578        assert!(w.extent.fraction().is_some(), "a complete walk must not read as unbounded");
579        fs::remove_dir_all(&root).ok();
580    }
581
582    #[test]
583    fn a_missing_directory_is_an_error_not_an_empty_world() {
584        // The alternative — returning a world with zero objects — is the accusation the
585        // provenance rules exist to prevent, one level up.
586        let err = RepoObserver.observe("definitely-not-a-real-path-9f2a").unwrap_err();
587        assert!(err.to_string().contains("cannot read"));
588    }
589
590    #[test]
591    fn a_manifest_only_unit_is_not_reported_as_untested_code() {
592        // A workspace root holding nothing but Cargo.toml has no tests, and saying so is a
593        // true statement wearing the same badge as a real finding. Noise at the same
594        // severity as signal is how an operator learns to stop reading the list.
595        let root = scratch();
596        write(&root, "Cargo.toml", "[workspace]
597members = [\"a\"]
598");
599        write(&root, "a/Cargo.toml", "[package]
600name = \"a\"
601");
602        write(&root, "a/src/lib.rs", "fn f() {}
603");
604        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
605
606        let untested: Vec<&str> = w.risks().map(|s| s.id.as_str()).collect();
607        assert!(
608            untested.iter().any(|id| id.ends_with(":a")),
609            "the crate with code must still be flagged, got {untested:?}"
610        );
611        let root_label = root.file_name().unwrap().to_string_lossy().to_string();
612        assert!(
613            !untested.iter().any(|id| id.ends_with(&format!(":{root_label}"))),
614            "the manifest-only workspace root must not be, got {untested:?}"
615        );
616        fs::remove_dir_all(&root).ok();
617    }
618
619    #[test]
620    fn the_locator_carries_no_platform_path_prefix() {
621        // The locator becomes the memory subject key and every signal target, so a prefix
622        // that varies with how the path was spelled splits one repository into two.
623        let root = scratch();
624        write(&root, "Cargo.toml", "[package]
625name = \"x\"
626");
627        write(&root, "src/lib.rs", "fn a() {}
628");
629        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
630        assert!(!w.entity.locator.starts_with(r"\?\"), "got {}", w.entity.locator);
631        fs::remove_dir_all(&root).ok();
632    }
633
634    #[test]
635    fn markers_are_counted_and_cited() {
636        let root = scratch();
637        write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
638        write(&root, "src/lib.rs", "// TODO: one\n// FIXME: two\n#[test]\nfn t() {}\n");
639        let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
640        let s = w
641            .opportunities()
642            .find(|s| s.id.starts_with("markers:"))
643            .expect("expected a marker signal");
644        assert!(s.evidence[0].contains("counted 2"), "got {:?}", s.evidence);
645        fs::remove_dir_all(&root).ok();
646    }
647}