Complete Workstream F: extend check-docs to cover CLI commands, wire into CI
All checks were successful
dev release / build (push) Successful in 1m6s
All checks were successful
dev release / build (push) Successful in 1m6s
Two remaining pieces from the original report, both explicitly signed off on: 1. Wire cargo run -p xtask -- check-docs into dev-release.yml as a fail-fast step ahead of the release build/test, so drift between api-schema.toml and the real API surface now fails CI instead of relying on local discipline. 2. Extend api-schema.toml and check-docs to cover the bread CLI's command surface (bread-cli/src/main.rs's Commands/ModulesCommand/ HooksCommand enums) against README.md's "CLI reference" section, not just Lua bindings/IPC methods against Documentation.md. This was the specific blind spot that let modules audit, hooks install-shell/install-git, and events --tree drift out of README in the first place -- check-docs would not have caught that fix without this extension, since its prior scope never touched the CLI-vs-README relationship at all. Extraction reuses the same deliberate-textual-scanning approach as the existing Lua/IPC extractors: depth-tracked enum variant scanning, clap's PascalCase->kebab-case rename convention, and a hardcoded (TABLE_VARS-style) map of which top-level Commands variants delegate to a nested subcommand enum (Modules->ModulesCommand, Hooks->HooksCommand), producing dotted names like modules.audit. CLI commands are versioned against the package version (Cargo.toml), not API_VERSION, since the CLI was never part of that versioned contract -- documented in api-schema.toml's header. Verified check-docs actually catches CLI/README drift, not just passes: temporarily deleted the "bread modules audit" line from README.md, confirmed check-docs failed with the exact right message, restored it. 4 new unit tests cover the extraction and cross-check logic (11 total in xtask, up from 7).
This commit is contained in:
parent
da889d6f6a
commit
a6973360bd
4 changed files with 459 additions and 64 deletions
|
|
@ -18,6 +18,13 @@ jobs:
|
||||||
git clone --branch main --depth 1 \
|
git clone --branch main --depth 1 \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
# Fails fast, before the expensive release build/test below, on any
|
||||||
|
# drift between api-schema.toml, the actual bread.*/IPC/CLI surface,
|
||||||
|
# Documentation.md, and README.md — see api-schema.toml's header and
|
||||||
|
# CONTRIBUTING.md's "Keeping the API docs honest" section.
|
||||||
|
- name: check-docs
|
||||||
|
run: cd src && cargo run -p xtask --locked -- check-docs
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: cd src && cargo build --release --locked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,37 +69,27 @@ cargo test --release --workspace
|
||||||
|
|
||||||
### Keeping the API docs honest
|
### Keeping the API docs honest
|
||||||
|
|
||||||
`Documentation.md`'s Lua API and IPC protocol sections are hand-written and
|
`Documentation.md`'s Lua API/IPC protocol sections and README's CLI
|
||||||
have drifted from the actual code before — there's a checked-in registry,
|
reference are hand-written and have drifted from the actual code before —
|
||||||
`api-schema.toml`, plus an `xtask` checker that catches it happening again.
|
there's a checked-in registry, `api-schema.toml`, plus an `xtask` checker
|
||||||
|
(`cargo run -p xtask -- check-docs`) that catches it happening again, and
|
||||||
|
it's enforced in CI (`dev-release.yml` fails the build on any drift).
|
||||||
|
|
||||||
Whenever you add, rename, or remove a `bread.*` Lua binding
|
Whenever you add, rename, or remove a `bread.*` Lua binding
|
||||||
(`breadd/src/lua/mod.rs`) or an IPC method (`breadd/src/ipc/mod.rs`):
|
(`breadd/src/lua/mod.rs`), an IPC method (`breadd/src/ipc/mod.rs`), or a
|
||||||
|
`bread` CLI command (`bread-cli/src/main.rs`):
|
||||||
|
|
||||||
1. Add/update/remove its entry in `api-schema.toml` to match.
|
1. Add/update/remove its entry in `api-schema.toml` to match.
|
||||||
2. Add/update the corresponding section in `Documentation.md` (a
|
2. Add/update the corresponding section — a `#### bread.<name>` heading in
|
||||||
`#### bread.<name>` heading for a Lua binding, or a row in the IPC
|
`Documentation.md` for a Lua binding, a row in `Documentation.md`'s IPC
|
||||||
Methods table for an IPC method).
|
Methods table for an IPC method, or a `bread <name>` line in `README.md`'s
|
||||||
|
"CLI reference" section for a CLI command.
|
||||||
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
||||||
a non-zero exit and a list of exactly what's out of sync — added but
|
a non-zero exit and a list of exactly what's out of sync — added but
|
||||||
undocumented, stale in the schema, or missing a doc heading/row — if
|
undocumented, stale in the schema, or missing a doc line — if
|
||||||
`api-schema.toml`, the code, and `Documentation.md` don't all agree.
|
`api-schema.toml`, the code, `Documentation.md`, and `README.md` don't
|
||||||
|
all agree. CI runs this too, so anything that slips past a local run
|
||||||
`check-docs` is **not yet wired into CI** — it's a local, manually-run
|
still fails the build rather than landing on `main`.
|
||||||
check today, not an enforced gate. That's a deliberate, still-open gap
|
|
||||||
(not an oversight): CI pipeline changes get a separate review pass before
|
|
||||||
landing, same as any other workflow-file edit. Wiring `cargo run -p xtask
|
|
||||||
-- check-docs` into `dev-release.yml` (fail the build on drift) is the
|
|
||||||
natural next step whenever that review happens — until then, discipline
|
|
||||||
running it before committing is what keeps `api-schema.toml`, the code,
|
|
||||||
and `Documentation.md` in sync, not anything automatic.
|
|
||||||
|
|
||||||
Also note `check-docs`'s scope: it covers `bread.*` Lua bindings and IPC
|
|
||||||
methods against `Documentation.md` only. It does not cover the `bread`
|
|
||||||
CLI's subcommands against `README.md`'s hand-written CLI reference —
|
|
||||||
that's a separate, currently-unguarded copy of information (see
|
|
||||||
`README.md`'s "CLI reference" section) and has drifted before for exactly
|
|
||||||
the same reason `Documentation.md` used to.
|
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
|
|
|
||||||
150
api-schema.toml
150
api-schema.toml
|
|
@ -1,13 +1,14 @@
|
||||||
# Bread Automation API schema — checked-in source-of-truth registry.
|
# Bread Automation API schema — checked-in source-of-truth registry.
|
||||||
#
|
#
|
||||||
# This is Workstream F (scoped down) from the governance-hardening report:
|
# This is Workstream F from the governance-hardening report: a *drift
|
||||||
# a *drift detector*, not a doc generator. `Documentation.md`'s "Dictionary:
|
# detector*, not a doc generator. `Documentation.md`'s "Dictionary: Lua API"
|
||||||
# Lua API" section is hand-written prose (one `#### bread.<name>(...)`
|
# section is hand-written prose (one `#### bread.<name>(...)` heading per
|
||||||
# heading per binding, with worked examples and edge-case notes) — nothing
|
# binding, with worked examples and edge-case notes) — nothing here
|
||||||
# here regenerates or reformats that prose. Instead, this file is the
|
# regenerates or reformats that prose. Instead, this file is the checked-in
|
||||||
# checked-in list of every `bread.*` Lua binding and IPC method that is
|
# list of every `bread.*` Lua binding, IPC method, and `bread` CLI command
|
||||||
# supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
# that is supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
||||||
# cross-checks it against:
|
# (wired into CI via .forgejo/workflows/dev-release.yml — fails the build on
|
||||||
|
# any drift) cross-checks it against:
|
||||||
#
|
#
|
||||||
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
||||||
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
||||||
|
|
@ -18,26 +19,49 @@
|
||||||
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
||||||
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
||||||
# `events.subscribe` streaming upgrade.
|
# `events.subscribe` streaming upgrade.
|
||||||
# 3. Documentation.md itself, to make sure each entry here still has a
|
# 3. The actual `bread` CLI commands declared in bread-cli/src/main.rs's
|
||||||
# `#### bread.<name>` heading (Lua) or a row in the IPC Methods table
|
# `Commands`/`ModulesCommand`/`HooksCommand` enums.
|
||||||
# (IPC methods).
|
# 4. Documentation.md, to make sure each lua_function/lua_table/ipc_method
|
||||||
|
# entry here still has a `#### bread.<name>` heading (Lua) or a row in
|
||||||
|
# the IPC Methods table (IPC methods).
|
||||||
|
# 5. README.md's "CLI reference" section, to make sure each cli_command
|
||||||
|
# entry here still has a `bread <name>` line there. This check exists
|
||||||
|
# because that section drifted from Documentation.md/the real CLI
|
||||||
|
# surface before check-docs covered it at all (missing `modules audit`,
|
||||||
|
# `hooks install-shell`/`install-git`, `events --tree`) — found and
|
||||||
|
# fixed by hand, then closed here so it can't recur silently.
|
||||||
#
|
#
|
||||||
# Whenever you add, rename, or remove a `bread.*` binding or IPC method:
|
# Whenever you add, rename, or remove a `bread.*` binding, an IPC method, or
|
||||||
|
# a `bread` CLI command:
|
||||||
# 1. Update this file to match.
|
# 1. Update this file to match.
|
||||||
# 2. Update/add the corresponding section in Documentation.md.
|
# 2. Update/add the corresponding section in Documentation.md (Lua/IPC)
|
||||||
|
# or README.md's CLI reference (CLI commands).
|
||||||
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
||||||
# loudly (non-zero exit) if the three are out of sync.
|
# loudly (non-zero exit) if the schema, the code, and the docs are out
|
||||||
|
# of sync. CI runs this too (dev-release.yml), so drift that slips past
|
||||||
|
# a local run still fails the build.
|
||||||
#
|
#
|
||||||
# `kind` is one of: "lua_function", "lua_table", "ipc_method".
|
# `kind` is one of: "lua_function", "lua_table", "ipc_method", "cli_command".
|
||||||
# `since` is the API version (Documentation.md's "API Stability &
|
#
|
||||||
# Versioning" section) the binding/method was introduced in. Anything from
|
# `since` means two different things depending on `kind`, because CLI
|
||||||
# the original v1.0 baseline (no `*(Since: vX.Y)*` marker in Documentation.md)
|
# commands were never part of the Bread Automation API's own versioned
|
||||||
# is listed as "1.0" here.
|
# contract (see Documentation.md's "API Stability & Versioning" section —
|
||||||
|
# it's explicitly scoped to "Lua API surface + IPC methods + event
|
||||||
|
# vocabulary + runtime-state schema", not the CLI):
|
||||||
|
# - lua_function / lua_table / ipc_method: the Bread Automation API
|
||||||
|
# version (breadd/src/ipc/mod.rs's `API_VERSION`) the binding/method was
|
||||||
|
# introduced in. Anything from the original v1.0 baseline (no
|
||||||
|
# `*(Since: vX.Y)*` marker in Documentation.md) is listed as "1.0" here.
|
||||||
|
# - cli_command: the `bread`/`breadd` package version (the workspace
|
||||||
|
# crates' `Cargo.toml` `version`, kept in lockstep — see CONTRIBUTING.md)
|
||||||
|
# the command was introduced in. Pre-existing commands as of this
|
||||||
|
# registry's creation are listed as "0.7" (the release before this one);
|
||||||
|
# no attempt was made to date them more precisely than that.
|
||||||
#
|
#
|
||||||
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
||||||
# that's checked and diffed against the actual API surface, and CI fails the
|
# that's checked and diffed against the actual API surface, and CI fails the
|
||||||
# build if they drift" option the source report names, as opposed to Rust
|
# build if they drift" option the source report names, as opposed to Rust
|
||||||
# attribute macros — overkill for a ~30-binding surface with no existing
|
# attribute macros — overkill for a ~45-entry surface with no existing
|
||||||
# proc-macro infrastructure in this workspace). TOML specifically because
|
# proc-macro infrastructure in this workspace). TOML specifically because
|
||||||
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
||||||
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
||||||
|
|
@ -498,3 +522,89 @@ since = "1.2"
|
||||||
name = "widgets.list"
|
name = "widgets.list"
|
||||||
kind = "ipc_method"
|
kind = "ipc_method"
|
||||||
since = "1.3"
|
since = "1.3"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI commands (bread-cli/src/main.rs) — checked against README.md's "CLI
|
||||||
|
# reference" section, not Documentation.md (which has no CLI section of its
|
||||||
|
# own). `since` here is the package version, not the API_VERSION — see this
|
||||||
|
# file's header.
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "reload"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "state"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "events"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.list"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.install"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.remove"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.info"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "modules.audit"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.8"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "hooks.install-shell"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "hooks.install-git"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "profile-list"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "profile-activate"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "emit"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "ping"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "health"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
||||||
|
[[entry]]
|
||||||
|
name = "doctor"
|
||||||
|
kind = "cli_command"
|
||||||
|
since = "0.7"
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@
|
||||||
//! binaries themselves.
|
//! binaries themselves.
|
||||||
//!
|
//!
|
||||||
//! Currently just `check-docs`: a drift detector between the actual
|
//! Currently just `check-docs`: a drift detector between the actual
|
||||||
//! `bread.*` Lua binding surface + IPC method surface (as implemented in
|
//! `bread.*` Lua binding surface + IPC method surface + `bread` CLI command
|
||||||
//! `breadd/src/lua/mod.rs` / `breadd/src/ipc/mod.rs`) and the checked-in
|
//! surface (as implemented in `breadd/src/lua/mod.rs`, `breadd/src/ipc/mod.rs`,
|
||||||
//! registry at `api-schema.toml`, cross-referenced against `Documentation.md`.
|
//! and `bread-cli/src/main.rs`) and the checked-in registry at
|
||||||
//! See `api-schema.toml`'s header comment for why this exists and why it's a
|
//! `api-schema.toml`, cross-referenced against `Documentation.md` (Lua/IPC)
|
||||||
//! *drift detector*, not a doc generator.
|
//! and `README.md` (CLI commands). See `api-schema.toml`'s header comment for
|
||||||
|
//! why this exists and why it's a *drift detector*, not a doc generator.
|
||||||
|
//!
|
||||||
|
//! The CLI-command coverage exists specifically because README's "CLI
|
||||||
|
//! reference" section drifted from `Documentation.md`/the real `bread-cli`
|
||||||
|
//! source (missing `modules audit`, `hooks install-shell`/`install-git`,
|
||||||
|
//! `events --tree`) — a live instance of exactly the drift this whole
|
||||||
|
//! workstream exists to prevent, found and fixed by hand once the schema
|
||||||
|
//! didn't cover it yet. See `api-schema.toml`'s header for how CLI commands
|
||||||
|
//! are named and versioned differently from the Lua/IPC entries.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -50,12 +59,16 @@ fn run_check_docs() -> Result<bool> {
|
||||||
.context("reading breadd/src/lua/mod.rs")?;
|
.context("reading breadd/src/lua/mod.rs")?;
|
||||||
let ipc_src = std::fs::read_to_string(root.join("breadd/src/ipc/mod.rs"))
|
let ipc_src = std::fs::read_to_string(root.join("breadd/src/ipc/mod.rs"))
|
||||||
.context("reading breadd/src/ipc/mod.rs")?;
|
.context("reading breadd/src/ipc/mod.rs")?;
|
||||||
|
let cli_src = std::fs::read_to_string(root.join("bread-cli/src/main.rs"))
|
||||||
|
.context("reading bread-cli/src/main.rs")?;
|
||||||
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
|
let schema_toml = std::fs::read_to_string(root.join("api-schema.toml"))
|
||||||
.context("reading api-schema.toml")?;
|
.context("reading api-schema.toml")?;
|
||||||
let doc_md =
|
let doc_md =
|
||||||
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
|
std::fs::read_to_string(root.join("Documentation.md")).context("reading Documentation.md")?;
|
||||||
|
let readme_md =
|
||||||
|
std::fs::read_to_string(root.join("README.md")).context("reading README.md")?;
|
||||||
|
|
||||||
let report = check(&lua_src, &ipc_src, &schema_toml, &doc_md)?;
|
let report = check(&lua_src, &ipc_src, &cli_src, &schema_toml, &doc_md, &readme_md)?;
|
||||||
report.print();
|
report.print();
|
||||||
Ok(report.is_clean())
|
Ok(report.is_clean())
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +100,7 @@ struct SchemaEntry {
|
||||||
since: String,
|
since: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method"];
|
const VALID_KINDS: &[&str] = &["lua_function", "lua_table", "ipc_method", "cli_command"];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
|
// Extraction: breadd/src/lua/mod.rs -> the current bread.* Lua API surface.
|
||||||
|
|
@ -259,6 +272,92 @@ fn extract_ipc_methods(src: &str) -> BTreeSet<String> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extraction: bread-cli/src/main.rs -> the current `bread` CLI command
|
||||||
|
// surface.
|
||||||
|
//
|
||||||
|
// Same deliberate textual-scanning approach as the Lua/IPC extractors
|
||||||
|
// above, not a real Rust/clap-derive-macro-expanding parser.
|
||||||
|
|
||||||
|
/// Top-level `Commands` variants that delegate to a nested subcommand enum
|
||||||
|
/// via `#[command(subcommand)] subcommand: XCommand` rather than being a
|
||||||
|
/// leaf command themselves — each such variant's own leaf commands are
|
||||||
|
/// named `<variant-kebab>.<subcommand-kebab>` (e.g. `modules.audit`,
|
||||||
|
/// `hooks.install-shell`) instead of the group variant itself being a leaf.
|
||||||
|
/// Update this list if `bread-cli/src/main.rs` gains another subcommand
|
||||||
|
/// group (the same kind of manual-update-needed list as `TABLE_VARS` above).
|
||||||
|
const SUBCOMMAND_GROUPS: &[(&str, &str)] = &[("Modules", "ModulesCommand"), ("Hooks", "HooksCommand")];
|
||||||
|
|
||||||
|
/// clap's default rename for a derived `Subcommand` variant: PascalCase ->
|
||||||
|
/// kebab-case (`ProfileList` -> `profile-list`, `InstallShell` ->
|
||||||
|
/// `install-shell`). This is what actually shows up as `bread <name>` on
|
||||||
|
/// the command line and in README's CLI reference.
|
||||||
|
fn kebab(pascal: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in pascal.chars().enumerate() {
|
||||||
|
if c.is_ascii_uppercase() {
|
||||||
|
if i != 0 {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
out.push(c.to_ascii_lowercase());
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan a `#[derive(Subcommand, ...)] enum <enum_name> { ... }` block for
|
||||||
|
/// its variant names, in source order. Depth-tracked the same way
|
||||||
|
/// `extract_ipc_methods` tracks match-arm depth, so a variant's own nested
|
||||||
|
/// struct-style fields (and their attributes/doc comments) aren't mistaken
|
||||||
|
/// for sibling variants.
|
||||||
|
fn extract_enum_variants(src: &str, enum_name: &str) -> Vec<String> {
|
||||||
|
let marker = format!("enum {enum_name} {{");
|
||||||
|
let Some(start) = src.find(&marker) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut depth: i32 = 0;
|
||||||
|
for line in src[start..].lines() {
|
||||||
|
let pre_depth = depth;
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
|
||||||
|
if pre_depth == 1 {
|
||||||
|
let name_end = trimmed
|
||||||
|
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||||
|
.unwrap_or(trimmed.len());
|
||||||
|
let name = &trimmed[..name_end];
|
||||||
|
if !name.is_empty() && name.starts_with(|c: char| c.is_ascii_uppercase()) {
|
||||||
|
out.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
depth += line.matches('{').count() as i32;
|
||||||
|
depth -= line.matches('}').count() as i32;
|
||||||
|
if pre_depth >= 1 && depth <= 0 {
|
||||||
|
break; // closed the enum block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cli_commands(src: &str) -> BTreeSet<String> {
|
||||||
|
let mut out = BTreeSet::new();
|
||||||
|
for variant in extract_enum_variants(src, "Commands") {
|
||||||
|
if let Some((_, sub_enum)) = SUBCOMMAND_GROUPS.iter().find(|(v, _)| *v == variant) {
|
||||||
|
let group = kebab(&variant);
|
||||||
|
for sub in extract_enum_variants(src, sub_enum) {
|
||||||
|
out.insert(format!("{group}.{}", kebab(&sub)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.insert(kebab(&variant));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Documentation.md cross-checks
|
// Documentation.md cross-checks
|
||||||
|
|
||||||
|
|
@ -283,6 +382,13 @@ fn ipc_section(doc: &str) -> &str {
|
||||||
section(doc, "## Dictionary: IPC protocol")
|
section(doc, "## Dictionary: IPC protocol")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// README.md cross-check
|
||||||
|
|
||||||
|
fn readme_cli_section(readme: &str) -> &str {
|
||||||
|
section(readme, "## CLI reference")
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Report
|
// Report
|
||||||
|
|
||||||
|
|
@ -293,8 +399,11 @@ struct CheckReport {
|
||||||
/// In api-schema.toml, no longer in code.
|
/// In api-schema.toml, no longer in code.
|
||||||
stale_in_schema: Vec<(String, String)>,
|
stale_in_schema: Vec<(String, String)>,
|
||||||
/// In api-schema.toml (and in code), but Documentation.md has no
|
/// In api-schema.toml (and in code), but Documentation.md has no
|
||||||
/// heading/row for it.
|
/// heading/row for it (lua_function/lua_table/ipc_method only).
|
||||||
undocumented: Vec<(String, String)>,
|
undocumented: Vec<(String, String)>,
|
||||||
|
/// In api-schema.toml (and in code), but README's "CLI reference"
|
||||||
|
/// section has no `bread <name>` line for it (cli_command only).
|
||||||
|
missing_from_readme: Vec<(String, String)>,
|
||||||
/// Schema entries with an unrecognized `kind`.
|
/// Schema entries with an unrecognized `kind`.
|
||||||
bad_kind: Vec<(String, String)>,
|
bad_kind: Vec<(String, String)>,
|
||||||
/// Schema entries with an empty `since`.
|
/// Schema entries with an empty `since`.
|
||||||
|
|
@ -306,13 +415,14 @@ impl CheckReport {
|
||||||
self.missing_from_schema.is_empty()
|
self.missing_from_schema.is_empty()
|
||||||
&& self.stale_in_schema.is_empty()
|
&& self.stale_in_schema.is_empty()
|
||||||
&& self.undocumented.is_empty()
|
&& self.undocumented.is_empty()
|
||||||
|
&& self.missing_from_readme.is_empty()
|
||||||
&& self.bad_kind.is_empty()
|
&& self.bad_kind.is_empty()
|
||||||
&& self.missing_since.is_empty()
|
&& self.missing_since.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print(&self) {
|
fn print(&self) {
|
||||||
if self.is_clean() {
|
if self.is_clean() {
|
||||||
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC surface and Documentation.md.");
|
println!("check-docs: OK — api-schema.toml matches breadd's Lua/IPC/CLI surface, Documentation.md, and README.md.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,6 +458,14 @@ impl CheckReport {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.missing_from_readme.is_empty() {
|
||||||
|
println!("In api-schema.toml but missing from README.md's \"CLI reference\" section (no `bread <name>` line):");
|
||||||
|
for (kind, name) in &self.missing_from_readme {
|
||||||
|
println!(" - [{kind}] {name}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
if !self.missing_since.is_empty() {
|
if !self.missing_since.is_empty() {
|
||||||
println!("Schema entries with an empty `since`:");
|
println!("Schema entries with an empty `since`:");
|
||||||
for (kind, name) in &self.missing_since {
|
for (kind, name) in &self.missing_since {
|
||||||
|
|
@ -360,7 +478,14 @@ impl CheckReport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Result<CheckReport> {
|
fn check(
|
||||||
|
lua_src: &str,
|
||||||
|
ipc_src: &str,
|
||||||
|
cli_src: &str,
|
||||||
|
schema_toml: &str,
|
||||||
|
doc_md: &str,
|
||||||
|
readme_md: &str,
|
||||||
|
) -> Result<CheckReport> {
|
||||||
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
|
let schema: Schema = toml::from_str(schema_toml).context("parsing api-schema.toml")?;
|
||||||
|
|
||||||
let mut report = CheckReport::default();
|
let mut report = CheckReport::default();
|
||||||
|
|
@ -383,7 +508,7 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
let code_lua = extract_lua_bindings(lua_src);
|
let code_lua = extract_lua_bindings(lua_src);
|
||||||
let schema_lua: BTreeSet<(String, String)> = schema_set
|
let schema_lua: BTreeSet<(String, String)> = schema_set
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(kind, _)| kind != "ipc_method")
|
.filter(|(kind, _)| kind == "lua_function" || kind == "lua_table")
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -413,12 +538,32 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
.push(("ipc_method".to_string(), name.clone()));
|
.push(("ipc_method".to_string(), name.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- schema vs Documentation.md ---
|
// --- code vs schema: CLI ---
|
||||||
|
let code_cli = extract_cli_commands(cli_src);
|
||||||
|
let schema_cli: BTreeSet<String> = schema_set
|
||||||
|
.iter()
|
||||||
|
.filter(|(kind, _)| kind == "cli_command")
|
||||||
|
.map(|(_, name)| name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for name in code_cli.difference(&schema_cli) {
|
||||||
|
report
|
||||||
|
.missing_from_schema
|
||||||
|
.push(("cli_command".to_string(), name.clone()));
|
||||||
|
}
|
||||||
|
for name in schema_cli.difference(&code_cli) {
|
||||||
|
report
|
||||||
|
.stale_in_schema
|
||||||
|
.push(("cli_command".to_string(), name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- schema vs Documentation.md (lua_function/lua_table/ipc_method only —
|
||||||
|
// cli_command is checked against README.md separately, below) ---
|
||||||
let lua_section = lua_api_section(doc_md);
|
let lua_section = lua_api_section(doc_md);
|
||||||
let ipc_tbl_section = ipc_section(doc_md);
|
let ipc_tbl_section = ipc_section(doc_md);
|
||||||
for entry in &schema.entry {
|
for entry in &schema.entry {
|
||||||
if !VALID_KINDS.contains(&entry.kind.as_str()) {
|
if !VALID_KINDS.contains(&entry.kind.as_str()) || entry.kind == "cli_command" {
|
||||||
continue; // already reported above
|
continue; // already reported above, or checked against README instead
|
||||||
}
|
}
|
||||||
let documented = if entry.kind == "ipc_method" {
|
let documented = if entry.kind == "ipc_method" {
|
||||||
ipc_tbl_section.contains(&format!("`{}`", entry.name))
|
ipc_tbl_section.contains(&format!("`{}`", entry.name))
|
||||||
|
|
@ -432,9 +577,24 @@ fn check(lua_src: &str, ipc_src: &str, schema_toml: &str, doc_md: &str) -> Resul
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- schema vs README.md (cli_command only) ---
|
||||||
|
let readme_cli = readme_cli_section(readme_md);
|
||||||
|
for entry in &schema.entry {
|
||||||
|
if entry.kind != "cli_command" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cmd_text = format!("bread {}", entry.name.replace('.', " "));
|
||||||
|
if !readme_cli.contains(cmd_text.as_str()) {
|
||||||
|
report
|
||||||
|
.missing_from_readme
|
||||||
|
.push((entry.kind.clone(), entry.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
report.missing_from_schema.sort();
|
report.missing_from_schema.sort();
|
||||||
report.stale_in_schema.sort();
|
report.stale_in_schema.sort();
|
||||||
report.undocumented.sort();
|
report.undocumented.sort();
|
||||||
|
report.missing_from_readme.sort();
|
||||||
report.bad_kind.sort();
|
report.bad_kind.sort();
|
||||||
report.missing_since.sort();
|
report.missing_since.sort();
|
||||||
|
|
||||||
|
|
@ -492,6 +652,43 @@ mod tests {
|
||||||
| `ping` | - | Connectivity check |
|
| `ping` | - | Connectivity check |
|
||||||
| `emit` | - | Inject an event |
|
| `emit` | - | Inject an event |
|
||||||
| `events.subscribe` | - | Upgrade to streaming mode |
|
| `events.subscribe` | - | Upgrade to streaming mode |
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Mirrors the real `bread-cli/src/main.rs` shape closely enough to
|
||||||
|
/// exercise both a flat leaf command (`Ping`) and a subcommand group
|
||||||
|
/// (`Modules` -> `ModulesCommand`), including a struct-style variant
|
||||||
|
/// with fields (`Info { name: String }`).
|
||||||
|
const CLI_SRC: &str = r#"
|
||||||
|
enum Commands {
|
||||||
|
/// Health check daemon connectivity
|
||||||
|
Ping,
|
||||||
|
/// Manage installed Lua modules
|
||||||
|
Modules {
|
||||||
|
#[command(subcommand)]
|
||||||
|
subcommand: ModulesCommand,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ModulesCommand {
|
||||||
|
/// List all installed modules
|
||||||
|
List,
|
||||||
|
/// Show full manifest details for a module
|
||||||
|
Info { name: String },
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const README_MD: &str = r#"
|
||||||
|
## CLI reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bread ping
|
||||||
|
bread modules list
|
||||||
|
bread modules info <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module system
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
|
fn schema_toml_for(entries: &[(&str, &str, &str)]) -> String {
|
||||||
|
|
@ -517,6 +714,9 @@ mod tests {
|
||||||
("ping", "ipc_method", "1.0"),
|
("ping", "ipc_method", "1.0"),
|
||||||
("emit", "ipc_method", "1.0"),
|
("emit", "ipc_method", "1.0"),
|
||||||
("events.subscribe", "ipc_method", "1.0"),
|
("events.subscribe", "ipc_method", "1.0"),
|
||||||
|
("ping", "cli_command", "0.7"),
|
||||||
|
("modules.list", "cli_command", "0.7"),
|
||||||
|
("modules.info", "cli_command", "0.7"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,7 +754,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_state_passes() {
|
fn clean_state_passes() {
|
||||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
let report = check(LUA_SRC, IPC_SRC, CLI_SRC, &full_schema(), DOC_MD, README_MD).unwrap();
|
||||||
assert!(report.is_clean(), "{report:#?}");
|
assert!(report.is_clean(), "{report:#?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,7 +764,15 @@ mod tests {
|
||||||
// updating api-schema.toml: schema still says "debounce", code no
|
// updating api-schema.toml: schema still says "debounce", code no
|
||||||
// longer does (only "debounce_v2" now exists).
|
// longer does (only "debounce_v2" now exists).
|
||||||
let renamed_lua_src = LUA_SRC.replace("bread.debounce", "bread.debounce_v2");
|
let renamed_lua_src = LUA_SRC.replace("bread.debounce", "bread.debounce_v2");
|
||||||
let report = check(&renamed_lua_src, IPC_SRC, &full_schema(), DOC_MD).unwrap();
|
let report = check(
|
||||||
|
&renamed_lua_src,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.missing_from_schema
|
.missing_from_schema
|
||||||
|
|
@ -577,7 +785,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn removed_ipc_method_is_caught_as_drift() {
|
fn removed_ipc_method_is_caught_as_drift() {
|
||||||
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
|
let without_emit = IPC_SRC.replacen("\"emit\" => {", "\"emit_removed\" => {", 1);
|
||||||
let report = check(LUA_SRC, &without_emit, &full_schema(), DOC_MD).unwrap();
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
&without_emit,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.stale_in_schema
|
.stale_in_schema
|
||||||
|
|
@ -587,7 +803,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_doc_heading_is_caught_as_drift() {
|
fn missing_doc_heading_is_caught_as_drift() {
|
||||||
let doc_without_spawn_heading = DOC_MD.replace("#### `bread.spawn(fn)`\n", "");
|
let doc_without_spawn_heading = DOC_MD.replace("#### `bread.spawn(fn)`\n", "");
|
||||||
let report = check(LUA_SRC, IPC_SRC, &full_schema(), &doc_without_spawn_heading).unwrap();
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
&doc_without_spawn_heading,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.undocumented
|
.undocumented
|
||||||
|
|
@ -597,10 +821,74 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_kind_is_rejected() {
|
fn unknown_kind_is_rejected() {
|
||||||
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
|
let bad_schema = schema_toml_for(&[("on", "lua_thing", "1.0")]);
|
||||||
let report = check(LUA_SRC, IPC_SRC, &bad_schema, DOC_MD).unwrap();
|
let report = check(LUA_SRC, IPC_SRC, CLI_SRC, &bad_schema, DOC_MD, README_MD).unwrap();
|
||||||
assert!(!report.is_clean());
|
assert!(!report.is_clean());
|
||||||
assert!(report
|
assert!(report
|
||||||
.bad_kind
|
.bad_kind
|
||||||
.contains(&("lua_thing".to_string(), "on".to_string())));
|
.contains(&("lua_thing".to_string(), "on".to_string())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kebab_converts_pascal_case_correctly() {
|
||||||
|
assert_eq!(kebab("Ping"), "ping");
|
||||||
|
assert_eq!(kebab("ProfileList"), "profile-list");
|
||||||
|
assert_eq!(kebab("InstallShell"), "install-shell");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_cli_commands_with_subcommand_groups_dotted() {
|
||||||
|
let got = extract_cli_commands(CLI_SRC);
|
||||||
|
let want: BTreeSet<String> = ["ping", "modules.list", "modules.info"]
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
got, want,
|
||||||
|
"flat leaf commands stay bare; subcommand-group members get `<group>.<sub>` names"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renamed_cli_command_is_caught_as_drift() {
|
||||||
|
// Simulate a contributor renaming the `modules info` subcommand in
|
||||||
|
// code (e.g. to `Show`) without updating api-schema.toml.
|
||||||
|
let renamed_cli_src = CLI_SRC.replace("Info { name: String }", "Show { name: String }");
|
||||||
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
&renamed_cli_src,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
README_MD,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!report.is_clean());
|
||||||
|
assert!(report
|
||||||
|
.missing_from_schema
|
||||||
|
.contains(&("cli_command".to_string(), "modules.show".to_string())));
|
||||||
|
assert!(report
|
||||||
|
.stale_in_schema
|
||||||
|
.contains(&("cli_command".to_string(), "modules.info".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_readme_cli_line_is_caught_as_drift() {
|
||||||
|
// The exact class of drift this coverage exists to catch: a real
|
||||||
|
// command exists in code and api-schema.toml, but README's "CLI
|
||||||
|
// reference" section never got a line added for it.
|
||||||
|
let readme_without_modules_info = README_MD.replace("bread modules info <name>\n", "");
|
||||||
|
let report = check(
|
||||||
|
LUA_SRC,
|
||||||
|
IPC_SRC,
|
||||||
|
CLI_SRC,
|
||||||
|
&full_schema(),
|
||||||
|
DOC_MD,
|
||||||
|
&readme_without_modules_info,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!report.is_clean());
|
||||||
|
assert!(report
|
||||||
|
.missing_from_readme
|
||||||
|
.contains(&("cli_command".to_string(), "modules.info".to_string())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue