use crate::audit::Audit;
use crate::audits::behavioral::subcommand_help::probe_subcommands;
use crate::project::Project;
use crate::runner::HelpOutput;
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
const STRUCTURED_OUTPUT_FLAG_NAMES: &[&str] =
&["--output", "--format", "--json", "--jsonl", "--ndjson"];
const STRUCTURED_OUTPUT_TOKENS: &[&str] = &["json", "jsonl", "ndjson", "JSON Lines"];
pub struct SchemaPrintAudit;
impl Audit for SchemaPrintAudit {
fn id(&self) -> &str {
"p2-schema-print"
}
fn label(&self) -> &'static str {
"Structured-output CLI exposes its schema at runtime"
}
fn group(&self) -> AuditGroup {
AuditGroup::P2
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p2-must-schema-print"]
}
fn applicable(&self, project: &Project) -> bool {
project.runner.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<AuditResult> {
let status = match project.help_output() {
None => AuditStatus::Skip("could not probe --help".into()),
Some(help) => {
match audit_schema_print(help) {
AuditStatus::Fail(_) => {
let runner = project.runner_ref();
let subhelp = probe_subcommands(runner, help);
audit_schema_print_with_subhelp(help, &subhelp)
}
other => other,
}
}
};
Ok(AuditResult {
id: self.id().to_string(),
label: self.label().into(),
group: self.group(),
layer: self.layer(),
status,
confidence: Confidence::Medium,
})
}
}
pub(crate) fn audit_schema_print(help: &HelpOutput) -> AuditStatus {
let raw = help.raw();
let raw_lower = raw.to_lowercase();
let has_structured_flag = help
.flags()
.iter()
.any(|f| STRUCTURED_OUTPUT_FLAG_NAMES.iter().any(|n| f.matches(n)));
let has_structured_token = STRUCTURED_OUTPUT_TOKENS
.iter()
.any(|t| raw_lower.contains(&t.to_lowercase()));
if !has_structured_flag && !has_structured_token {
return AuditStatus::Skip(
"no structured-output indicator (--output / --format / json / jsonl) in --help".into(),
);
}
let has_schema_flag = help.flags().iter().any(|f| f.matches("--schema"));
if has_schema_flag {
return AuditStatus::Pass;
}
let schema_in_subcommands = help
.subcommands()
.iter()
.any(|s| s.eq_ignore_ascii_case("schema"));
let schema_section_match = raw
.lines()
.any(|line| line.starts_with(" ") && line.trim_start().starts_with("schema"));
if schema_in_subcommands || schema_section_match {
return AuditStatus::Pass;
}
AuditStatus::Fail(
"CLI emits structured output but exposes no `schema` subcommand or \
`--schema` flag. Agents need a runtime-discoverable schema to pin \
against shape changes."
.into(),
)
}
pub(crate) fn audit_schema_print_with_subhelp(
top_help: &HelpOutput,
subhelp: &[(String, HelpOutput)],
) -> AuditStatus {
match audit_schema_print(top_help) {
AuditStatus::Fail(_) => {}
other => return other,
}
for (_name, help) in subhelp {
let has_schema_flag = help.flags().iter().any(|f| f.matches("--schema"));
if has_schema_flag {
return AuditStatus::Pass;
}
let schema_in_subs = help
.subcommands()
.iter()
.any(|s| s.eq_ignore_ascii_case("schema"));
if schema_in_subs {
return AuditStatus::Pass;
}
let schema_section_match = help
.raw()
.lines()
.any(|line| line.starts_with(" ") && line.trim_start().starts_with("schema"));
if schema_section_match {
return AuditStatus::Pass;
}
}
AuditStatus::Fail(
"CLI emits structured output but exposes no `schema` subcommand or \
`--schema` flag at top level or nested one level deep. Agents need \
a runtime-discoverable schema to pin against shape changes."
.into(),
)
}
#[cfg(test)]
mod tests {
use super::*;
const HELP_WITH_SCHEMA_SUBCMD: &str = r#"Usage: tool [OPTIONS] [COMMAND]
Commands:
audit Run audits
schema Print the JSON output schema
Options:
--output <FORMAT> Output format (text or json)
-h, --help Show help
"#;
const HELP_WITH_SCHEMA_FLAG: &str = r#"Usage: tool [OPTIONS]
Options:
--output <FORMAT> Output format
--schema Print the JSON output schema
-h, --help Show help
"#;
const HELP_NO_STRUCTURED_OUTPUT: &str = r#"Usage: tool [OPTIONS]
Options:
-q, --quiet Suppress output
-h, --help Show help
"#;
const HELP_STRUCTURED_NO_SCHEMA: &str = r#"Usage: tool [OPTIONS]
Outputs JSON when --json is set.
Options:
--json Emit JSON
-h, --help Show help
"#;
#[test]
fn happy_path_schema_subcommand() {
let help = HelpOutput::from_raw(HELP_WITH_SCHEMA_SUBCMD);
assert_eq!(audit_schema_print(&help), AuditStatus::Pass);
}
#[test]
fn happy_path_schema_flag() {
let help = HelpOutput::from_raw(HELP_WITH_SCHEMA_FLAG);
assert_eq!(audit_schema_print(&help), AuditStatus::Pass);
}
#[test]
fn skip_no_structured_output_indicator() {
let help = HelpOutput::from_raw(HELP_NO_STRUCTURED_OUTPUT);
match audit_schema_print(&help) {
AuditStatus::Skip(msg) => assert!(msg.contains("structured-output")),
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn fail_structured_output_no_schema() {
let help = HelpOutput::from_raw(HELP_STRUCTURED_NO_SCHEMA);
match audit_schema_print(&help) {
AuditStatus::Fail(msg) => assert!(msg.contains("schema")),
other => panic!("expected Fail, got {other:?}"),
}
}
}