Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Forge developer convenience targets.
# CI enforces fmt, clippy (-D warnings), tests, and the documentation drift gate.

.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-examples docs-baseline install-hooks fmt clippy test check ci
.PHONY: docs-check docs-report docs-api docs-counts docs-crates docs-examples docs-cli docs-baseline install-hooks fmt clippy test check ci

# Documentation drift gate (baseline-aware): fails only on NEW drift beyond the
# recorded baseline. Same logic as CI and the pre-commit hook.
Expand All @@ -28,6 +28,11 @@ docs-crates:
docs-examples:
cargo run -q -p forge-docs-check -- --write-example-blocks

# Regenerate the <!-- forge:cli --> reference in crates/forge.md from the
# forge_cli clap command model (subcommands, args, options).
docs-cli:
cargo run -q -p forge-docs-check -- --write-cli

# Full drift report: list ALL current drift (fails if any exists at all).
# Useful for seeing the whole backlog; not the burn-down gate.
docs-report:
Expand Down
5 changes: 5 additions & 0 deletions crates/forge-docs-check/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ anyhow = "1"
toml = "0.8"
# Match SDK exports, doc headings, count phrases, and CLI usage strings.
regex = "1"
# Introspect the real `forge` CLI surface (the clap command model exposed by
# `forge_cli::cli`) to generate the CLI reference and gate it against drift —
# the docs and the parser are the same model, so they cannot diverge.
clap = "4"
forge_cli = { path = "../forge_cli" }

[dev-dependencies]
# Drift-rule unit tests build throwaway crate/doc trees on disk.
Expand Down
164 changes: 25 additions & 139 deletions crates/forge-docs-check/src/checks/cli_commands.rs
Original file line number Diff line number Diff line change
@@ -1,44 +1,27 @@
//! Rule `cli-command`: every `forge` subcommand is documented in `crates/forge.md`.
//!
//! Caught the missing `forge smelt` entry. The command list is sourced from the
//! CLI's own clap command model in `forge_cli/src/main.rs` (a single in-repo
//! source of truth), so adding a subcommand there surfaces the documentation
//! requirement automatically.
//! Caught the missing `forge smelt` entry. The command list is sourced directly
//! from the clap command model ([`forge_cli::cli`]) — the same model the binary
//! parses with — so adding a subcommand surfaces the documentation requirement
//! automatically, with no source-text parsing or name-mangling to keep in sync.
//!
//! Before the Phase 5 clap migration this was sourced from a hand-written
//! `forge <a|b|c>` usage banner; clap derive replaced that banner with the
//! `#[derive(Subcommand)] enum Commands { .. }` block, so this rule now parses
//! the enum variants and applies clap's default PascalCase -> kebab-case rename
//! to recover the exact subcommand names the CLI exposes.
//! This is the lightweight *presence* guard (each subcommand is mentioned
//! somewhere on the page, prose or generated). The richer
//! [`crate::clidoc`] rule (`cli-doc`) regenerates the full `<!-- forge:cli -->`
//! reference — synopsis, args, options — from the same model.

use crate::checks::read_optional;
use crate::discovery::Workspace;
use crate::Finding;

pub fn check(ws: &Workspace) -> Vec<Finding> {
let main_rs = ws.root.join("crates/forge_cli/src/main.rs");
let main_src = match read_optional(&main_rs) {
Some(s) => s,
None => {
return vec![Finding::new(
"cli-command",
format!(
"cannot read {} to determine the subcommand list",
main_rs.display()
),
)]
}
};

let commands = match parse_subcommands(&main_src) {
Some(c) if !c.is_empty() => c,
_ => {
return vec![Finding::new(
"cli-command",
"could not find the `#[derive(Subcommand)] enum Commands` block in forge_cli/src/main.rs to source the command list",
)]
}
};
let commands = subcommand_names();
if commands.is_empty() {
return vec![Finding::new(
"cli-command",
"forge_cli::cli() exposes no subcommands — the CLI model could not be introspected",
)];
}

let forge_md = ws.docs_dir().join("crates/forge.md");
let doc = match read_optional(&forge_md) {
Expand All @@ -53,7 +36,8 @@ pub fn check(ws: &Workspace) -> Vec<Finding> {

let mut findings = Vec::new();
for cmd in commands {
// Documented as a `forge <cmd>` reference (heading or code/example).
// Documented as a `forge <cmd>` reference (heading, prose, or the
// generated CLI reference block).
if !doc.contains(&format!("forge {cmd}")) {
findings.push(Finding::new(
"cli-command",
Expand All @@ -66,110 +50,12 @@ pub fn check(ws: &Workspace) -> Vec<Finding> {
findings
}

/// Extract the top-level variant names from the clap `enum Commands { .. }`
/// block and convert each to the kebab-case name clap exposes on the CLI.
fn parse_subcommands(src: &str) -> Option<Vec<String>> {
let body = enum_body(src, "Commands")?;

let mut variants = Vec::new();
let mut depth = 0i32;
for line in body.lines() {
let trimmed = line.trim();
// A variant name is a PascalCase identifier sitting at the enum's top
// level (depth 0). Struct-variant fields live at depth >= 1, and
// attributes/doc-comments (`#[..]`, `///`) don't start with an
// uppercase identifier, so both are skipped naturally.
if depth == 0 {
if let Some(name) = variant_name(trimmed) {
variants.push(to_kebab(&name));
}
}
depth += line.matches('{').count() as i32;
depth -= line.matches('}').count() as i32;
if depth < 0 {
depth = 0;
}
}

if variants.is_empty() {
None
} else {
Some(variants)
}
}

/// Return the brace-delimited body of `enum <name>`, matching braces so nested
/// struct-variant `{ .. }` blocks don't terminate the scan early.
fn enum_body<'a>(src: &'a str, name: &str) -> Option<&'a str> {
let needle = format!("enum {name}");
let start = src.find(&needle)?;
let open = src[start..].find('{')? + start;

let bytes = src.as_bytes();
let mut depth = 0i32;
let mut body_start = open + 1;
let mut i = open;
while i < bytes.len() {
match bytes[i] {
b'{' => {
depth += 1;
if depth == 1 {
body_start = i + 1;
}
}
b'}' => {
depth -= 1;
if depth == 0 {
return Some(&src[body_start..i]);
}
}
_ => {}
}
i += 1;
}
None
}

/// If `line` begins a variant declaration, return its identifier. Accepts the
/// PascalCase name when it is followed by variant syntax — `{` (struct
/// variant), `(` (tuple variant), `,`, or end of line (unit variant).
fn variant_name(line: &str) -> Option<String> {
let first = line.chars().next()?;
if !first.is_ascii_uppercase() {
return None;
}
let end = line
.char_indices()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
.map(|(idx, _)| idx)
.unwrap_or(line.len());
let ident = &line[..end];
let rest = line[end..].trim_start();
if rest.is_empty() || rest.starts_with('{') || rest.starts_with('(') || rest.starts_with(',') {
Some(ident.to_string())
} else {
None
}
}

/// Replicate clap's default subcommand rename (PascalCase -> kebab-case).
///
/// This is a simple per-uppercase-boundary split, which matches clap's
/// `heck`-based kebab-casing for single-word variants (every current forge
/// subcommand). It diverges only on multi-word acronyms — `HTTPServer` here
/// becomes `h-t-t-p-server` where clap/heck emits `http-server`. No forge
/// subcommand triggers that, so the simpler form is kept intentionally.
fn to_kebab(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_ascii_uppercase() {
if i != 0 {
out.push('-');
}
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
out
/// The user-invocable top-level subcommand names from the clap model, skipping
/// clap's auto-generated `help` command and any hidden subcommands.
fn subcommand_names() -> Vec<String> {
forge_cli::cli()
.get_subcommands()
.filter(|s| s.get_name() != "help" && !s.is_hide_set())
.map(|s| s.get_name().to_string())
.collect()
}
Loading
Loading