open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-brownfield/examples/scan_self.rs
Transaction Science 8135e999ea ci: gate jouleclaw-rs public surface against documentation drift
After the 219 → 0 curation pass that closed every Lost public
reason in jouleclaw-rs, the next concern is regression — every
new `pub fn` landing without a `///` doc would silently drift
the count back up. This workflow makes that impossible.

NEW BEHAVIOUR

  scan_self.rs gains a `--fail-on-lost` flag that exits non-
  zero if the public-surface Lost count is greater than zero.
  Default behaviour (no flag) is unchanged — the example
  still prints the report and returns 0 for ad-hoc local runs.

  .github/workflows/jouleclaw-public-surface-documented.yml
  runs `cargo run -q -p jouleclaw-brownfield --example
  scan_self -- --fail-on-lost` on every PR + main push that
  touches `jouleclaw/jouleclaw-rs/**`. Failure surfaces a
  GitHub error annotation pointing at the fix:

    Fix by adding a `///` doc comment above each flagged
    `pub fn`, OR by downgrading to `pub(crate)` if it isn't
    consumer-visible.

    Run locally with:
      cargo run -p jouleclaw-brownfield --example scan_self

  Path-scoped so unrelated PRs (other open-standards crates,
  docs, etc.) don't pay the build cost. Cached cargo registry
  + target so steady-state runs are fast.

  Pinned to Rust 1.96 (the workspace rust-version) via
  dtolnay/rust-toolchain.

DESIGN NOTES

  - Same example serves humans (locally, no flag → readable
    report) and CI (with flag → enforcement). No second
    binary, no script duplication.

  - The check is "public-surface-only Lost == 0", NOT
    "all-functions Lost == 0". Internal helpers are
    deliberately excluded per substrate doctrine — `///` on
    every `pub(crate)` helper isn't load-bearing.

  - Includes the workflow file itself in the path filter so
    workflow edits trigger a self-test on the same PR.

WHAT THIS GUARANTEES

  Public surface stays at zero Lost forever. Every PR adding
  a new `pub fn` either ships with a doc or fails CI. No
  drift back to 219.

cargo run --example scan_self -- --fail-on-lost: exits 0
locally on main.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-06-08 04:49:28 -04:00

153 lines
5.1 KiB
Rust

//! Run the brownfield onramp against jouleclaw-rs itself.
//!
//! Walks `crates/*/src/**/*.rs` + `crates/*/Cargo.toml`,
//! produces a [`jouleclaw_brownfield::CodebaseMap`], runs
//! [`jouleclaw_brownfield::reverse_compile`], and prints the
//! markdown report.
//!
//! Run from the workspace root:
//!
//! ```bash
//! cargo run -p jouleclaw-brownfield --example scan_self
//! ```
//!
//! Pipes cleanly into a file:
//!
//! ```bash
//! cargo run -p jouleclaw-brownfield --example scan_self \
//! > BROWNFIELD-REPORT.md
//! ```
use jouleclaw_brownfield::{
reverse_compile, reverse_compile_public_surface, reverse_compile_report,
scan_corpus, ReasonConfidence,
};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = locate_workspace_root()?;
let crates_dir = workspace_root.join("crates");
eprintln!("scanning {}", crates_dir.display());
let mut corpus: Vec<(PathBuf, String)> = Vec::new();
let mut skipped: u32 = 0;
for entry in WalkDir::new(&crates_dir).into_iter().filter_map(Result::ok) {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let include = ext == "rs" || name == "Cargo.toml";
if !include {
continue;
}
// Skip targets / .git just in case.
if path
.components()
.any(|c| c.as_os_str() == "target" || c.as_os_str() == ".git")
{
skipped += 1;
continue;
}
match std::fs::read_to_string(path) {
Ok(content) => {
let rel = path
.strip_prefix(&workspace_root)
.unwrap_or(path)
.to_path_buf();
corpus.push((rel, content));
}
Err(_) => skipped += 1,
}
}
eprintln!(
"collected {} files ({} skipped); running scan_corpus + reverse_compile",
corpus.len(),
skipped
);
let map = scan_corpus(&corpus)?;
let full = reverse_compile(&map, "jouleclaw-rs");
let public = reverse_compile_public_surface(&map, "jouleclaw-rs (public surface)");
let lost = full
.module_purposes
.iter()
.filter(|r| r.confidence == ReasonConfidence::Lost)
.count();
let documented = full
.module_purposes
.iter()
.filter(|r| r.confidence == ReasonConfidence::Documented)
.count();
eprintln!(
"modules: {} | documented: {} | lost: {} | currency gaps: {}",
full.module_purposes.len(),
documented,
lost,
full.missing_currency.len()
);
eprintln!(
"missing reasons — all functions: {} | public surface only: {} | internal-only delta: {}",
full.missing_reasons.len(),
public.missing_reasons.len(),
full.missing_reasons
.len()
.saturating_sub(public.missing_reasons.len())
);
// Print the public-surface report by default — that's the
// actionable audit. The full report is much larger and
// available via the `--full` arg.
let full_mode = std::env::args().any(|a| a == "--full");
if full_mode {
println!("{}", reverse_compile_report(&full));
} else {
println!("{}", reverse_compile_report(&public));
}
// CI gate: --fail-on-lost makes the process exit non-zero
// when any public-surface Lost reason remains. Used by the
// jouleclaw-public-surface-documented workflow as a
// regression-prevention check.
if std::env::args().any(|a| a == "--fail-on-lost")
&& !public.missing_reasons.is_empty()
{
eprintln!(
"\nFAIL: {} public-surface Lost reasons remain. \
See the report above for the per-crate breakdown + full list. \
Close them with `///` doc comments or downgrade the function \
to `pub(crate)` if it isn't part of the consumer-visible surface.",
public.missing_reasons.len()
);
std::process::exit(1);
}
Ok(())
}
/// Walk up from the example binary's location until we find a
/// directory containing a `crates/` subdirectory + a top-level
/// `Cargo.toml` — that's the workspace root jouleclaw-rs sits
/// inside.
fn locate_workspace_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
// Cargo runs examples from CARGO_MANIFEST_DIR (this
// crate's dir); the workspace root is two levels up
// (crates/jouleclaw-brownfield → crates → root).
let manifest_dir =
std::env::var("CARGO_MANIFEST_DIR").map(PathBuf::from)?;
let candidate = manifest_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or("manifest dir has no grandparent")?;
if !candidate.join("crates").is_dir() {
return Err(format!(
"couldn't find `crates/` under workspace root {}",
candidate.display()
)
.into());
}
Ok(candidate)
}