use crate::audit::Audit;
use crate::project::Project;
use crate::runner::{BinaryRunner, HelpOutput, RunStatus};
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
const COMMON_VERBS: &[&str] = &[
"add", "audit", "build", "create", "delete", "deploy", "describe", "destroy", "edit", "emit",
"export", "fetch", "generate", "get", "import", "init", "install", "list", "ls", "make", "new",
"publish", "pull", "push", "remove", "rm", "run", "search", "serve", "set", "show", "start",
"status", "stop", "test", "update", "upgrade", "view",
];
const CLAP_BUILTINS: &[&str] = &["help", "completions", "completion", "complete"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Classification {
TopLevelVerb,
HierarchicalNounVerb,
Mixed,
}
fn is_verb(name: &str) -> bool {
COMMON_VERBS.iter().any(|v| name.eq_ignore_ascii_case(v))
}
fn is_clap_builtin(name: &str) -> bool {
CLAP_BUILTINS.iter().any(|s| name.eq_ignore_ascii_case(s))
}
fn classify(name: &str, children: &[String]) -> Classification {
if is_verb(name) {
return Classification::TopLevelVerb;
}
let real_children: Vec<&String> = children.iter().filter(|c| !is_clap_builtin(c)).collect();
if real_children.is_empty() {
return Classification::TopLevelVerb;
}
let verb_count = real_children.iter().filter(|c| is_verb(c)).count();
let non_verb_count = real_children.len() - verb_count;
if verb_count > 0 && non_verb_count == 0 {
Classification::HierarchicalNounVerb
} else if verb_count == 0 {
Classification::TopLevelVerb
} else {
Classification::Mixed
}
}
pub struct ConsistentNamingAudit;
impl Audit for ConsistentNamingAudit {
fn id(&self) -> &str {
"p6-consistent-naming"
}
fn label(&self) -> &'static str {
"Subcommand naming follows a consistent verb/noun convention"
}
fn group(&self) -> AuditGroup {
AuditGroup::P6
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p6-should-consistent-naming"]
}
fn applicable(&self, project: &Project) -> bool {
project.runner.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<AuditResult> {
let status = match (project.help_output(), project.runner.as_ref()) {
(Some(help), Some(runner)) => {
let probe = |name: &str| probe_subcommand_children(runner, name);
audit_consistent_naming(help, probe)
}
_ => AuditStatus::Skip("could not probe --help".into()),
};
Ok(AuditResult {
id: self.id().to_string(),
label: self.label().into(),
group: self.group(),
layer: self.layer(),
status,
confidence: Confidence::Medium,
})
}
}
fn probe_subcommand_children(runner: &BinaryRunner, name: &str) -> Vec<String> {
let result = runner.run(&[name, "--help"], &[]);
match result.status {
RunStatus::Ok | RunStatus::Timeout | RunStatus::Crash { .. } => {
let mut raw = String::with_capacity(result.stdout.len() + result.stderr.len());
raw.push_str(&result.stdout);
raw.push_str(&result.stderr);
if raw.trim().is_empty() {
return Vec::new();
}
HelpOutput::from_raw(raw).subcommands().to_vec()
}
_ => Vec::new(),
}
}
pub(crate) fn audit_consistent_naming(
top_help: &HelpOutput,
children_of: impl Fn(&str) -> Vec<String>,
) -> AuditStatus {
let cmds: Vec<&String> = top_help
.subcommands()
.iter()
.filter(|s| !is_clap_builtin(s))
.collect();
if cmds.len() < 2 {
return AuditStatus::Skip(
"fewer than 2 user-defined subcommands; vacuous skip for the conditional SHOULD."
.into(),
);
}
let mut top_level_verb: Vec<&str> = Vec::new();
let mut hierarchical: Vec<&str> = Vec::new();
let mut mixed: Vec<&str> = Vec::new();
for cmd in &cmds {
let children = if is_verb(cmd) {
Vec::new() } else {
children_of(cmd.as_str())
};
match classify(cmd, &children) {
Classification::TopLevelVerb => top_level_verb.push(cmd.as_str()),
Classification::HierarchicalNounVerb => hierarchical.push(cmd.as_str()),
Classification::Mixed => mixed.push(cmd.as_str()),
}
}
if mixed.is_empty() {
AuditStatus::Pass
} else {
AuditStatus::Warn(format!(
"subcommand naming is inconsistent: {} non-verb subcommand(s) ({}) mix verb and non-verb children at the second level, so an agent cannot predict where the action lives. SHOULD-tier: pick a consistent shape (all verb-first, all noun-verb hierarchy, or any combination where each non-verb group's children are uniformly verbs). The verb list is a heuristic; inspect `--help` to confirm.",
mixed.len(),
mixed.join(", "),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_children(_: &str) -> Vec<String> {
Vec::new()
}
#[test]
fn pass_when_all_verbs() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n list List items.\n add Add an item.\n remove Remove.\n",
);
assert_eq!(
audit_consistent_naming(&help, no_children),
AuditStatus::Pass
);
}
#[test]
fn pass_when_all_nouns_with_verb_children() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n config Manage config.\n cluster Manage clusters.\n",
);
let probe = |name: &str| match name {
"config" => vec!["get".into(), "set".into()],
"cluster" => vec!["create".into(), "delete".into()],
_ => vec![],
};
assert_eq!(audit_consistent_naming(&help, probe), AuditStatus::Pass);
}
#[test]
fn pass_when_anc_pattern() {
let help = HelpOutput::from_raw(
"Usage: anc [COMMAND]\n\nCommands:\n audit Audit a CLI.\n emit Render artifacts.\n skill Manage the skill bundle.\n",
);
let probe = |name: &str| match name {
"emit" => vec!["coverage-matrix".into(), "schema".into()],
"skill" => vec!["install".into(), "update".into()],
_ => vec![],
};
assert_eq!(audit_consistent_naming(&help, probe), AuditStatus::Pass);
}
#[test]
fn pass_when_mixed_top_level_with_consistent_subtrees() {
let help = HelpOutput::from_raw(
"Usage: vcs [COMMAND]\n\nCommands:\n pull Pull from remote.\n push Push to remote.\n remote Manage remotes.\n branch Manage branches.\n",
);
let probe = |name: &str| match name {
"remote" => vec!["add".into(), "remove".into()],
"branch" => vec!["list".into(), "delete".into()],
_ => vec![],
};
assert_eq!(audit_consistent_naming(&help, probe), AuditStatus::Pass);
}
#[test]
fn warn_when_noun_has_truly_mixed_children() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n build Build it.\n config Manage config.\n",
);
let probe = |name: &str| match name {
"config" => vec!["get".into(), "credentials".into(), "set".into()], _ => vec![],
};
match audit_consistent_naming(&help, probe) {
AuditStatus::Warn(msg) => {
assert!(msg.contains("inconsistent"));
assert!(msg.contains("config"));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn pass_when_non_verb_top_level_has_no_children() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n build Build it.\n metadata Show project metadata.\n version Show version.\n",
);
assert_eq!(
audit_consistent_naming(&help, no_children),
AuditStatus::Pass
);
}
#[test]
fn skip_when_only_one_command() {
let help = HelpOutput::from_raw("Usage: tool [COMMAND]\n\nCommands:\n build Build.\n");
match audit_consistent_naming(&help, no_children) {
AuditStatus::Skip(_) => {}
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn skip_when_only_clap_builtins() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n help Print help.\n completions Generate completions.\n",
);
match audit_consistent_naming(&help, no_children) {
AuditStatus::Skip(_) => {}
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn clap_builtins_in_children_are_filtered() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n audit Audit.\n config Manage config.\n",
);
let probe = |name: &str| match name {
"config" => vec![
"get".into(),
"set".into(),
"help".into(),
"completions".into(),
],
_ => vec![],
};
assert_eq!(audit_consistent_naming(&help, probe), AuditStatus::Pass);
}
#[test]
fn warn_message_lists_offending_subcommands() {
let help = HelpOutput::from_raw(
"Usage: tool [COMMAND]\n\nCommands:\n audit Audit.\n config Config.\n user User.\n",
);
let probe = |name: &str| match name {
"config" => vec!["get".into(), "schema".into()], "user" => vec!["create".into(), "profile".into()], _ => vec![],
};
match audit_consistent_naming(&help, probe) {
AuditStatus::Warn(msg) => {
assert!(msg.contains("config"));
assert!(msg.contains("user"));
assert!(msg.contains('2'));
}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn classify_helpers_handle_case_insensitively() {
assert!(is_verb("AUDIT"));
assert!(is_verb("Audit"));
assert!(is_verb("audit"));
assert!(is_clap_builtin("HELP"));
assert!(is_clap_builtin("Completions"));
assert!(!is_verb("skill"));
assert!(!is_clap_builtin("audit"));
}
}