use std::borrow::Cow;
use std::collections::BTreeSet;
use std::path::Path;
include!(concat!(env!("OUT_DIR"), "/grammar_registry.rs"));
pub(crate) struct RegistryFact {
pub name: &'static str,
pub path: &'static str,
pub source_exists: bool,
pub tests_exist: bool,
}
use anyhow::Context;
use clap::{Arg, Command, CommandFactory};
use serde::{Deserialize, Serialize};
pub const KINDS: &[(&str, &[&str])] = &[
("legacy", &["claw", "molt", "pinch"]),
("orchestration", &["cast", "cascade_copy"]),
("tooling", &["consortium_grammar"]),
];
pub const SHARED_FLAGS: &[&str] = &["verbose", "color", "format"];
pub const PINNED_SELECTORS: &[(&str, &str, &[&str])] = &[
(
"claw",
"",
&[
"nodes",
"exclude",
"all",
"group",
"exclude-group",
"pick",
"hostfile",
],
),
(
"pinch",
"",
&[
"exclude",
"intersection",
"xor",
"all",
"group",
"pick",
"slice",
"index",
],
),
("molt", "", &[]),
("cast", "eval", &["on", "tag"]),
("cast", "status", &["on", "tag"]),
("cast", "build", &["on", "tag", "builders", "fanout"]),
(
"cast",
"deploy",
&[
"on",
"tag",
"builders",
"fanout",
"cascade",
"cascade-fanout",
"activate-timeout",
],
),
("cast", "health", &[]),
("cast", "tree", &["max-depth", "no-color"]),
("cast", "verify", &["fanout", "nodes"]),
(
"cast",
"live",
&[
"nodes",
"strategy",
"order",
"fanout",
"seeds",
"seed-fraction",
"closure-mb",
"bandwidth",
"uplinks",
"seed",
"failure-rate",
"failure-seed",
"no-watch",
"per-round-delay",
"max-depth",
"no-color",
],
),
];
pub const PINNED_SHORT_FLAGS: &[(&str, &str)] = &[
("claw", "wxagXfltuRobLNqS"),
("molt", "bLSTG"),
("pinch", "ceflrxiXagSRIO"),
];
pub fn manifest_bins(manifest: &Path) -> anyhow::Result<Vec<(String, String)>> {
let raw = std::fs::read_to_string(manifest)?;
let value: toml::Value = toml::from_str(&raw)?;
let bins = value
.get("bin")
.and_then(|b| b.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[]);
Ok(bins
.iter()
.filter_map(|b| {
let name = b.get("name")?.as_str()?.to_string();
let path = b
.get("path")
.and_then(|p| p.as_str())
.map(|p| p.to_string())
.unwrap_or_else(|| format!("src/bin/{name}.rs"));
Some((name, path))
})
.collect())
}
fn kind_bins() -> impl Iterator<Item = &'static str> {
KINDS.iter().flat_map(|(_, bins)| bins.iter().copied())
}
struct DeclaredBin<'a> {
name: &'a str,
path: &'a str,
source_exists: bool,
tests_exist: bool,
}
fn embedded_registry() -> impl Iterator<Item = DeclaredBin<'static>> {
REGISTRY_FACTS.iter().map(|f| DeclaredBin {
name: f.name,
path: f.path,
source_exists: f.source_exists,
tests_exist: f.tests_exist,
})
}
fn registry_findings<'a>(
declared: anyhow::Result<impl IntoIterator<Item = DeclaredBin<'a>>>,
) -> Vec<Violation> {
let mut v = Vec::new();
let declared = match declared {
Ok(d) => d,
Err(e) => {
v.push(Violation {
rule: "registry".to_string(),
subcommand: String::new(),
bin: String::new(),
arg: "manifest".to_string(),
detail: format!("unreadable Cargo.toml: {e}"),
});
return v;
}
};
let mut declared_underscored: BTreeSet<Cow<'a, str>> = BTreeSet::new();
for b in declared {
let us = if b.name.contains('-') {
Cow::Owned(b.name.replace('-', "_"))
} else {
Cow::Borrowed(b.name)
};
if !kind_bins().any(|bin| bin == us.as_ref()) {
v.push(Violation {
rule: "registry".to_string(),
subcommand: String::new(),
bin: b.name.to_string(),
arg: "kind".to_string(),
detail: format!("bin '{}' is not classified in KINDS", b.name),
});
}
if !b.source_exists {
v.push(Violation {
rule: "registry".to_string(),
subcommand: String::new(),
bin: b.name.to_string(),
arg: "source".to_string(),
detail: format!("missing declared source {}", b.path),
});
}
if !b.tests_exist {
v.push(Violation {
rule: "registry".to_string(),
subcommand: String::new(),
bin: b.name.to_string(),
arg: "tests".to_string(),
detail: format!("missing tests/{us}_tests.rs"),
});
}
declared_underscored.insert(us);
}
for bin in kind_bins() {
if !declared_underscored.contains(bin) {
v.push(Violation {
rule: "registry".to_string(),
subcommand: String::new(),
bin: bin.to_string(),
arg: "manifest".to_string(),
detail: format!("KINDS bin '{bin}' has no [[bin]] in Cargo.toml"),
});
}
}
v
}
pub fn registry_violations(manifest: &Path) -> Vec<Violation> {
let root = manifest.parent().unwrap_or_else(|| Path::new("."));
let bins = match manifest_bins(manifest) {
Ok(bins) => bins,
Err(error) => {
return registry_findings(Err::<std::iter::Empty<DeclaredBin<'static>>, _>(error));
}
};
registry_findings(Ok(bins.iter().map(|(name, path)| {
let us = name.replace('-', "_");
let tests = format!("{us}_tests.rs");
DeclaredBin {
source_exists: root.join(path).exists(),
tests_exist: root.join("tests").join(&tests).exists(),
name: name.as_str(),
path: path.as_str(),
}
})))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Row {
pub bin: String,
pub subcommand: String,
pub arg: String,
pub kind: String,
pub short: Option<char>,
pub long: Option<String>,
pub r#type: String,
pub default: String,
pub help: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Violation {
pub rule: String,
pub bin: String,
pub subcommand: String,
pub arg: String,
pub detail: String,
}
pub const ROOT: &str = "(root)";
pub fn key_of(v: &Violation) -> String {
let sub = if v.subcommand.is_empty() {
ROOT
} else {
&v.subcommand
};
format!("{} {} {} {}", v.rule, v.bin, sub, v.arg)
}
fn arg_kind(a: &Arg) -> &'static str {
if a.is_positional() {
"positional"
} else if a.get_num_args().map(|n| n.takes_values()).unwrap_or(false) {
"flag"
} else {
"switch"
}
}
fn walk(cmd: &Command, bin: &str, subcommand: &str, rows: &mut Vec<Row>) {
for a in cmd.get_arguments() {
let r#type = a
.get_value_names()
.and_then(|n| n.first())
.map(|n| n.to_string())
.unwrap_or_default();
let default = a
.get_default_values()
.iter()
.map(|v| v.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" ");
rows.push(Row {
bin: bin.to_string(),
subcommand: subcommand.to_string(),
arg: a.get_id().to_string(),
kind: arg_kind(a).to_string(),
short: a.get_short(),
long: a.get_long().map(|l| l.to_string()),
r#type,
default,
help: a.get_help().map(|h| h.to_string()).unwrap_or_default(),
});
}
for sc in cmd.get_subcommands() {
walk(sc, bin, sc.get_name(), rows);
}
}
pub fn dump() -> Vec<Row> {
let mut rows = Vec::new();
let commands = [
("claw", crate::claw::Args::command()),
("molt", crate::molt::Args::command()),
("pinch", crate::pinch::Args::command()),
("cast", crate::cast::Args::command()),
];
for (bin, cmd) in &commands {
walk(cmd, bin, "", &mut rows);
}
for bin in kind_bins().filter(|b| !commands.iter().any(|(name, _)| name == b)) {
rows.push(Row {
bin: (*bin).to_string(),
subcommand: String::new(),
arg: String::new(),
kind: "registry".to_string(),
short: None,
long: None,
r#type: String::new(),
default: String::new(),
help: "registered bin (not introspected)".to_string(),
});
}
rows
}
pub const PINNED_ROOT_LONGS: &[(&str, &[&str])] = &[
(
"claw",
&[
"nodes",
"exclude",
"all",
"group",
"exclude-group",
"pick",
"hostfile",
"fanout",
"user",
"connect-timeout",
"command-timeout",
"worker",
"options",
"remote",
"topology",
"dshbak",
"line",
"label",
"quiet",
"maxrc",
"axis",
"verbose",
"color",
"format",
"copy",
"rcopy",
"dest",
],
),
(
"molt",
&[
"dshbak",
"line",
"separator",
"tree",
"groupbase",
"axis",
"interpret-keys",
"verbose",
"color",
"format",
],
),
(
"pinch",
&[
"count",
"expand",
"fold",
"list",
"regroup",
"groupsources",
"exclude",
"intersection",
"xor",
"all",
"group",
"separator",
"rangeset",
"autostep",
"split",
"contiguous",
"pick",
"slice",
"index",
"output-format",
"verbose",
"color",
"format",
],
),
(
"cast",
&[
"config",
"flake",
"user",
"nix-args",
"darwin-nix-args",
"nixos-nix-args",
"verbose",
"color",
"format",
],
),
];
fn global_flags(rows: &[Row], out: &mut Vec<Violation>) {
for (bin, pinned) in PINNED_ROOT_LONGS {
let want: BTreeSet<&str> = pinned.iter().copied().collect();
let got: BTreeSet<&str> = rows
.iter()
.filter(|r| {
r.bin == *bin
&& r.subcommand.is_empty()
&& r.kind != "registry"
&& r.arg != "help"
&& r.arg != "version"
})
.filter_map(|r| r.long.as_deref())
.collect();
for missing in want.difference(&got) {
out.push(Violation {
rule: "global-flags".to_string(),
subcommand: String::new(),
bin: (*bin).to_string(),
arg: (*missing).to_string(),
detail: "pinned root long option missing".to_string(),
});
}
for unexpected in got.difference(&want) {
out.push(Violation {
rule: "global-flags".to_string(),
subcommand: String::new(),
bin: (*bin).to_string(),
arg: (*unexpected).to_string(),
detail: "unexpected root long option".to_string(),
});
}
}
}
const NON_SELECTOR_LONGS: &[&str] = &["verbose", "color", "format", "help", "version"];
fn selectors(rows: &[Row], out: &mut Vec<Violation>) {
let exec_options: [&str; 5] = [
"fanout",
"builders",
"cascade",
"cascade-fanout",
"activate-timeout",
];
let vocab: BTreeSet<&str> = PINNED_SELECTORS
.iter()
.flat_map(|(_, _, longs)| longs.iter().copied())
.filter(|l| !exec_options.contains(l))
.collect();
for (bin, sub, pinned) in PINNED_SELECTORS {
let want: BTreeSet<&str> = pinned.iter().copied().collect();
let candidates: BTreeSet<&str> = rows
.iter()
.filter(|r| {
r.bin == *bin
&& r.subcommand == *sub
&& r.kind != "registry"
&& r.kind != "positional"
})
.filter_map(|r| r.long.as_deref())
.filter(|l| !NON_SELECTOR_LONGS.contains(l))
.collect();
for missing in want.difference(&candidates) {
out.push(Violation {
rule: "selector".to_string(),
subcommand: (*sub).to_string(),
bin: (*bin).to_string(),
arg: (*missing).to_string(),
detail: "pinned selector missing from the live grammar".to_string(),
});
}
let strict = *bin == "cast";
for unexpected in candidates.difference(&want) {
if vocab.contains(unexpected) || strict {
out.push(Violation {
rule: "selector".to_string(),
subcommand: (*sub).to_string(),
bin: (*bin).to_string(),
arg: (*unexpected).to_string(),
detail: if strict {
"cast subcommand selector outside the pinned whitelist".to_string()
} else {
"selector-like option not allowed on this command".to_string()
},
});
}
}
}
}
pub fn violations() -> Vec<Violation> {
let rows = dump();
let mut v = Vec::new();
global_flags(&rows, &mut v);
selectors(&rows, &mut v);
v.extend(registry_findings(Ok::<_, anyhow::Error>(
embedded_registry(),
)));
for (bin, pinned) in PINNED_SHORT_FLAGS {
let got: BTreeSet<char> = rows
.iter()
.filter(|r| r.bin == *bin && !SHARED_FLAGS.contains(&r.arg.as_str()))
.filter_map(|r| r.short)
.collect();
let want: BTreeSet<char> = pinned.chars().collect();
if got != want {
v.push(Violation {
rule: "compat".to_string(),
subcommand: String::new(),
bin: (*bin).to_string(),
arg: "short-flags".to_string(),
detail: format!("frozen upstream set drift: got {got:?} want {want:?}"),
});
}
}
typed_missing_help(&rows, &mut v);
v
}
fn typed_missing_help(rows: &[Row], out: &mut Vec<Violation>) {
out.extend(
rows.iter()
.filter(|r| {
r.kind != "registry" && r.help.is_empty() && r.arg != "help" && r.arg != "version"
})
.map(|r| Violation {
rule: "typed".to_string(),
subcommand: r.subcommand.clone(),
bin: r.bin.clone(),
arg: r.arg.clone(),
detail: "argument has no help text".to_string(),
}),
);
}
pub fn check_keys(live: &[String], baseline: &[String]) -> anyhow::Result<usize> {
let live_set: BTreeSet<&String> = live.iter().collect();
let baseline_set: BTreeSet<&String> = baseline.iter().collect();
if live_set.len() != live.len() {
return Err(anyhow::anyhow!(
"grammar check failed: duplicate keys in live set: {live:?}"
));
}
if baseline_set.len() != baseline.len() {
return Err(anyhow::anyhow!(
"grammar check failed: duplicate keys in baseline: {baseline:?}"
));
}
let new: Vec<&String> = live_set.difference(&baseline_set).copied().collect();
let stale: Vec<&String> = baseline_set.difference(&live_set).copied().collect();
if !new.is_empty() || !stale.is_empty() {
return Err(anyhow::anyhow!(
"grammar check failed: {new_count} new, {stale_count} stale\nnew: {new:?}\nstale: {stale:?}",
new_count = new.len(),
stale_count = stale.len()
));
}
Ok(live.len())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckReport {
pub ok: bool,
pub baseline: usize,
pub violations: usize,
}
pub fn check(baseline: &Path) -> anyhow::Result<CheckReport> {
let source = std::fs::read_to_string(baseline).with_context(|| {
format!(
"grammar check failed: cannot read baseline {}",
baseline.display()
)
})?;
let base: Vec<String> = serde_json::from_str(&source)?;
let live = violations();
let keys: Vec<String> = live.iter().map(key_of).collect();
let count = check_keys(&keys, &base)?;
Ok(CheckReport {
ok: true,
baseline: base.len(),
violations: count,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn typed_keys_distinguish_same_arg_across_subcommands() {
let mk = |sub: &str| Row {
bin: "cast".to_string(),
subcommand: sub.to_string(),
arg: "on".to_string(),
kind: "flag".to_string(),
short: None,
long: None,
r#type: String::new(),
default: String::new(),
help: String::new(),
};
let mut v = Vec::new();
typed_missing_help(&[mk("build"), mk("deploy")], &mut v);
let keys: BTreeSet<String> = v.iter().map(key_of).collect();
assert_eq!(
keys,
BTreeSet::from([
"typed cast build on".to_string(),
"typed cast deploy on".to_string(),
]),
"same arg ID on distinct subcommands must produce two distinct typed keys"
);
}
#[test]
fn global_flags_rejects_missing_and_unexpected_root_longs() {
let mut rows = dump();
assert!(
{
let mut v = Vec::new();
global_flags(&rows, &mut v);
v
}
.is_empty(),
"current tree must satisfy the pinned root long sets"
);
rows.push(Row {
bin: "cast".to_string(),
subcommand: String::new(),
arg: "prefix".to_string(),
kind: "switch".to_string(),
short: None,
long: Some("prefix".to_string()),
r#type: String::new(),
default: String::new(),
help: "synthetic".to_string(),
});
let keys: BTreeSet<String> = {
let mut v = Vec::new();
global_flags(&rows, &mut v);
v.iter().map(key_of).collect()
};
assert_eq!(
keys,
BTreeSet::from(["global-flags cast (root) prefix".to_string()]),
"synthetic long-only flag must be rejected as unexpected"
);
for r in rows.iter_mut() {
if r.bin == "cast" && r.arg == "format" {
r.long = Some("output-format".to_string());
}
}
let keys: BTreeSet<String> = {
let mut v = Vec::new();
global_flags(&rows, &mut v);
v.iter().map(key_of).collect()
};
assert!(
keys.contains("global-flags cast (root) format"),
"renamed spelling must violate as missing: {keys:?}"
);
assert!(
keys.contains("global-flags cast (root) output-format"),
"renamed spelling must violate as unexpected: {keys:?}"
);
}
#[test]
fn selector_rule_rejects_unknown_and_missing_selectors() {
let mut rows = dump();
assert!(
{
let mut v = Vec::new();
selectors(&rows, &mut v);
v
}
.is_empty(),
"live dump must satisfy the pinned selector sets"
);
let mut push = |bin: &str, sub: &str, arg: &str| {
rows.push(Row {
bin: bin.to_string(),
subcommand: sub.to_string(),
arg: arg.to_string(),
kind: "flag".to_string(),
short: None,
long: Some(arg.to_string()),
r#type: String::new(),
default: String::new(),
help: "synthetic".to_string(),
})
};
push("molt", "", "on");
push("cast", "health", "on");
push("cast", "eval", "zone");
let keys: BTreeSet<String> = {
let mut v = Vec::new();
selectors(&rows, &mut v);
v.iter().map(key_of).collect()
};
assert_eq!(
keys,
BTreeSet::from([
"selector molt (root) on".to_string(),
"selector cast health on".to_string(),
"selector cast eval zone".to_string(),
]),
"unknown selectors must violate: {keys:?}"
);
rows.retain(|r| !(r.bin == "cast" && r.subcommand == "deploy" && r.arg == "tag"));
rows.retain(|r| !(r.bin == "claw" && r.subcommand.is_empty() && r.arg == "nodes"));
let keys: BTreeSet<String> = {
let mut v = Vec::new();
selectors(&rows, &mut v);
v.iter().map(key_of).collect()
};
assert!(
keys.contains("selector cast deploy tag"),
"removed cast deploy --tag must violate as missing: {keys:?}"
);
assert!(
keys.contains("selector claw (root) nodes"),
"removed claw --nodes must violate as missing: {keys:?}"
);
}
}