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]>
153 lines
5.1 KiB
Rust
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)
|
|
}
|