1use std::borrow::Cow;
25use std::collections::BTreeSet;
26use std::path::Path;
27
28include!(concat!(env!("OUT_DIR"), "/grammar_registry.rs"));
32
33pub(crate) struct RegistryFact {
35 pub name: &'static str,
36 pub path: &'static str,
37 pub source_exists: bool,
38 pub tests_exist: bool,
39}
40
41use anyhow::Context;
42use clap::{Arg, Command, CommandFactory};
43use serde::{Deserialize, Serialize};
44
45pub const KINDS: &[(&str, &[&str])] = &[
51 ("legacy", &["claw", "molt", "pinch"]),
52 ("orchestration", &["cast", "cascade_copy"]),
53 ("tooling", &["consortium_grammar"]),
54];
55
56pub const SHARED_FLAGS: &[&str] = &["verbose", "color", "format"];
59
60pub const PINNED_SELECTORS: &[(&str, &str, &[&str])] = &[
69 (
70 "claw",
71 "",
72 &[
73 "nodes",
74 "exclude",
75 "all",
76 "group",
77 "exclude-group",
78 "pick",
79 "hostfile",
80 ],
81 ),
82 (
83 "pinch",
84 "",
85 &[
86 "exclude",
87 "intersection",
88 "xor",
89 "all",
90 "group",
91 "pick",
92 "slice",
93 "index",
94 ],
95 ),
96 ("molt", "", &[]),
97 ("cast", "eval", &["on", "tag"]),
98 ("cast", "status", &["on", "tag"]),
99 ("cast", "build", &["on", "tag", "builders", "fanout"]),
100 (
101 "cast",
102 "deploy",
103 &[
104 "on",
105 "tag",
106 "builders",
107 "fanout",
108 "cascade",
109 "cascade-fanout",
110 "activate-timeout",
111 ],
112 ),
113 ("cast", "health", &[]),
114 ("cast", "tree", &["max-depth", "no-color"]),
115 ("cast", "verify", &["fanout", "nodes"]),
116 (
117 "cast",
118 "live",
119 &[
120 "nodes",
121 "strategy",
122 "order",
123 "fanout",
124 "seeds",
125 "seed-fraction",
126 "closure-mb",
127 "bandwidth",
128 "uplinks",
129 "seed",
130 "failure-rate",
131 "failure-seed",
132 "no-watch",
133 "per-round-delay",
134 "max-depth",
135 "no-color",
136 ],
137 ),
138];
139
140pub const PINNED_SHORT_FLAGS: &[(&str, &str)] = &[
144 ("claw", "wxagXfltuRobLNqS"),
145 ("molt", "bLSTG"),
146 ("pinch", "ceflrxiXagSRIO"),
147];
148
149pub fn manifest_bins(manifest: &Path) -> anyhow::Result<Vec<(String, String)>> {
153 let raw = std::fs::read_to_string(manifest)?;
154 let value: toml::Value = toml::from_str(&raw)?;
155 let bins = value
156 .get("bin")
157 .and_then(|b| b.as_array())
158 .map(|a| a.as_slice())
159 .unwrap_or(&[]);
160 Ok(bins
161 .iter()
162 .filter_map(|b| {
163 let name = b.get("name")?.as_str()?.to_string();
164 let path = b
165 .get("path")
166 .and_then(|p| p.as_str())
167 .map(|p| p.to_string())
168 .unwrap_or_else(|| format!("src/bin/{name}.rs"));
169 Some((name, path))
170 })
171 .collect())
172}
173
174fn kind_bins() -> impl Iterator<Item = &'static str> {
176 KINDS.iter().flat_map(|(_, bins)| bins.iter().copied())
177}
178
179struct DeclaredBin<'a> {
182 name: &'a str,
183 path: &'a str,
184 source_exists: bool,
185 tests_exist: bool,
186}
187
188fn embedded_registry() -> impl Iterator<Item = DeclaredBin<'static>> {
189 REGISTRY_FACTS.iter().map(|f| DeclaredBin {
190 name: f.name,
191 path: f.path,
192 source_exists: f.source_exists,
193 tests_exist: f.tests_exist,
194 })
195}
196
197fn registry_findings<'a>(
204 declared: anyhow::Result<impl IntoIterator<Item = DeclaredBin<'a>>>,
205) -> Vec<Violation> {
206 let mut v = Vec::new();
207 let declared = match declared {
208 Ok(d) => d,
209 Err(e) => {
210 v.push(Violation {
211 rule: "registry".to_string(),
212 subcommand: String::new(),
213 bin: String::new(),
214 arg: "manifest".to_string(),
215 detail: format!("unreadable Cargo.toml: {e}"),
216 });
217 return v;
218 }
219 };
220 let mut declared_underscored: BTreeSet<Cow<'a, str>> = BTreeSet::new();
221 for b in declared {
222 let us = if b.name.contains('-') {
223 Cow::Owned(b.name.replace('-', "_"))
224 } else {
225 Cow::Borrowed(b.name)
226 };
227 if !kind_bins().any(|bin| bin == us.as_ref()) {
228 v.push(Violation {
229 rule: "registry".to_string(),
230 subcommand: String::new(),
231 bin: b.name.to_string(),
232 arg: "kind".to_string(),
233 detail: format!("bin '{}' is not classified in KINDS", b.name),
234 });
235 }
236 if !b.source_exists {
237 v.push(Violation {
238 rule: "registry".to_string(),
239 subcommand: String::new(),
240 bin: b.name.to_string(),
241 arg: "source".to_string(),
242 detail: format!("missing declared source {}", b.path),
243 });
244 }
245 if !b.tests_exist {
246 v.push(Violation {
247 rule: "registry".to_string(),
248 subcommand: String::new(),
249 bin: b.name.to_string(),
250 arg: "tests".to_string(),
251 detail: format!("missing tests/{us}_tests.rs"),
252 });
253 }
254 declared_underscored.insert(us);
255 }
256 for bin in kind_bins() {
257 if !declared_underscored.contains(bin) {
258 v.push(Violation {
259 rule: "registry".to_string(),
260 subcommand: String::new(),
261 bin: bin.to_string(),
262 arg: "manifest".to_string(),
263 detail: format!("KINDS bin '{bin}' has no [[bin]] in Cargo.toml"),
264 });
265 }
266 }
267 v
268}
269
270pub fn registry_violations(manifest: &Path) -> Vec<Violation> {
274 let root = manifest.parent().unwrap_or_else(|| Path::new("."));
275 let bins = match manifest_bins(manifest) {
276 Ok(bins) => bins,
277 Err(error) => {
278 return registry_findings(Err::<std::iter::Empty<DeclaredBin<'static>>, _>(error));
279 }
280 };
281 registry_findings(Ok(bins.iter().map(|(name, path)| {
282 let us = name.replace('-', "_");
283 let tests = format!("{us}_tests.rs");
284 DeclaredBin {
285 source_exists: root.join(path).exists(),
286 tests_exist: root.join("tests").join(&tests).exists(),
287 name: name.as_str(),
288 path: path.as_str(),
289 }
290 })))
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct Row {
297 pub bin: String,
298 pub subcommand: String,
299 pub arg: String,
300 pub kind: String,
302 pub short: Option<char>,
303 pub long: Option<String>,
306 pub r#type: String,
307 pub default: String,
308 pub help: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct Violation {
317 pub rule: String,
318 pub bin: String,
319 pub subcommand: String,
320 pub arg: String,
321 pub detail: String,
322}
323
324pub const ROOT: &str = "(root)";
327
328pub fn key_of(v: &Violation) -> String {
330 let sub = if v.subcommand.is_empty() {
331 ROOT
332 } else {
333 &v.subcommand
334 };
335 format!("{} {} {} {}", v.rule, v.bin, sub, v.arg)
336}
337
338fn arg_kind(a: &Arg) -> &'static str {
339 if a.is_positional() {
340 "positional"
341 } else if a.get_num_args().map(|n| n.takes_values()).unwrap_or(false) {
342 "flag"
343 } else {
344 "switch"
345 }
346}
347
348fn walk(cmd: &Command, bin: &str, subcommand: &str, rows: &mut Vec<Row>) {
349 for a in cmd.get_arguments() {
350 let r#type = a
351 .get_value_names()
352 .and_then(|n| n.first())
353 .map(|n| n.to_string())
354 .unwrap_or_default();
355 let default = a
356 .get_default_values()
357 .iter()
358 .map(|v| v.to_string_lossy().to_string())
359 .collect::<Vec<_>>()
360 .join(" ");
361 rows.push(Row {
362 bin: bin.to_string(),
363 subcommand: subcommand.to_string(),
364 arg: a.get_id().to_string(),
365 kind: arg_kind(a).to_string(),
366 short: a.get_short(),
367 long: a.get_long().map(|l| l.to_string()),
368 r#type,
369 default,
370 help: a.get_help().map(|h| h.to_string()).unwrap_or_default(),
371 });
372 }
373 for sc in cmd.get_subcommands() {
374 walk(sc, bin, sc.get_name(), rows);
375 }
376}
377
378pub fn dump() -> Vec<Row> {
381 let mut rows = Vec::new();
382 let commands = [
383 ("claw", crate::claw::Args::command()),
384 ("molt", crate::molt::Args::command()),
385 ("pinch", crate::pinch::Args::command()),
386 ("cast", crate::cast::Args::command()),
387 ];
388 for (bin, cmd) in &commands {
389 walk(cmd, bin, "", &mut rows);
390 }
391 for bin in kind_bins().filter(|b| !commands.iter().any(|(name, _)| name == b)) {
393 rows.push(Row {
394 bin: (*bin).to_string(),
395 subcommand: String::new(),
396 arg: String::new(),
397 kind: "registry".to_string(),
398 short: None,
399 long: None,
400 r#type: String::new(),
401 default: String::new(),
402 help: "registered bin (not introspected)".to_string(),
403 });
404 }
405 rows
406}
407
408pub const PINNED_ROOT_LONGS: &[(&str, &[&str])] = &[
412 (
413 "claw",
414 &[
415 "nodes",
416 "exclude",
417 "all",
418 "group",
419 "exclude-group",
420 "pick",
421 "hostfile",
422 "fanout",
423 "user",
424 "connect-timeout",
425 "command-timeout",
426 "worker",
427 "options",
428 "remote",
429 "topology",
430 "dshbak",
431 "line",
432 "label",
433 "quiet",
434 "maxrc",
435 "axis",
436 "verbose",
437 "color",
438 "format",
439 "copy",
440 "rcopy",
441 "dest",
442 ],
443 ),
444 (
445 "molt",
446 &[
447 "dshbak",
448 "line",
449 "separator",
450 "tree",
451 "groupbase",
452 "axis",
453 "interpret-keys",
454 "verbose",
455 "color",
456 "format",
457 ],
458 ),
459 (
460 "pinch",
461 &[
462 "count",
463 "expand",
464 "fold",
465 "list",
466 "regroup",
467 "groupsources",
468 "exclude",
469 "intersection",
470 "xor",
471 "all",
472 "group",
473 "separator",
474 "rangeset",
475 "autostep",
476 "split",
477 "contiguous",
478 "pick",
479 "slice",
480 "index",
481 "output-format",
482 "verbose",
483 "color",
484 "format",
485 ],
486 ),
487 (
488 "cast",
489 &[
490 "config",
491 "flake",
492 "user",
493 "nix-args",
494 "darwin-nix-args",
495 "nixos-nix-args",
496 "verbose",
497 "color",
498 "format",
499 ],
500 ),
501];
502
503fn global_flags(rows: &[Row], out: &mut Vec<Violation>) {
508 for (bin, pinned) in PINNED_ROOT_LONGS {
509 let want: BTreeSet<&str> = pinned.iter().copied().collect();
510 let got: BTreeSet<&str> = rows
511 .iter()
512 .filter(|r| {
513 r.bin == *bin
514 && r.subcommand.is_empty()
515 && r.kind != "registry"
516 && r.arg != "help"
517 && r.arg != "version"
518 })
519 .filter_map(|r| r.long.as_deref())
520 .collect();
521 for missing in want.difference(&got) {
522 out.push(Violation {
523 rule: "global-flags".to_string(),
524 subcommand: String::new(),
525 bin: (*bin).to_string(),
526 arg: (*missing).to_string(),
527 detail: "pinned root long option missing".to_string(),
528 });
529 }
530 for unexpected in got.difference(&want) {
531 out.push(Violation {
532 rule: "global-flags".to_string(),
533 subcommand: String::new(),
534 bin: (*bin).to_string(),
535 arg: (*unexpected).to_string(),
536 detail: "unexpected root long option".to_string(),
537 });
538 }
539 }
540}
541
542const NON_SELECTOR_LONGS: &[&str] = &["verbose", "color", "format", "help", "version"];
545
546fn selectors(rows: &[Row], out: &mut Vec<Violation>) {
554 let exec_options: [&str; 5] = [
561 "fanout",
562 "builders",
563 "cascade",
564 "cascade-fanout",
565 "activate-timeout",
566 ];
567 let vocab: BTreeSet<&str> = PINNED_SELECTORS
568 .iter()
569 .flat_map(|(_, _, longs)| longs.iter().copied())
570 .filter(|l| !exec_options.contains(l))
571 .collect();
572 for (bin, sub, pinned) in PINNED_SELECTORS {
573 let want: BTreeSet<&str> = pinned.iter().copied().collect();
574 let candidates: BTreeSet<&str> = rows
575 .iter()
576 .filter(|r| {
577 r.bin == *bin
578 && r.subcommand == *sub
579 && r.kind != "registry"
580 && r.kind != "positional"
581 })
582 .filter_map(|r| r.long.as_deref())
583 .filter(|l| !NON_SELECTOR_LONGS.contains(l))
584 .collect();
585 for missing in want.difference(&candidates) {
586 out.push(Violation {
587 rule: "selector".to_string(),
588 subcommand: (*sub).to_string(),
589 bin: (*bin).to_string(),
590 arg: (*missing).to_string(),
591 detail: "pinned selector missing from the live grammar".to_string(),
592 });
593 }
594 let strict = *bin == "cast";
595 for unexpected in candidates.difference(&want) {
596 if vocab.contains(unexpected) || strict {
597 out.push(Violation {
598 rule: "selector".to_string(),
599 subcommand: (*sub).to_string(),
600 bin: (*bin).to_string(),
601 arg: (*unexpected).to_string(),
602 detail: if strict {
603 "cast subcommand selector outside the pinned whitelist".to_string()
604 } else {
605 "selector-like option not allowed on this command".to_string()
606 },
607 });
608 }
609 }
610 }
611}
612
613pub fn violations() -> Vec<Violation> {
615 let rows = dump();
616 let mut v = Vec::new();
617
618 global_flags(&rows, &mut v);
619
620 selectors(&rows, &mut v);
621
622 v.extend(registry_findings(Ok::<_, anyhow::Error>(
624 embedded_registry(),
625 )));
626
627 for (bin, pinned) in PINNED_SHORT_FLAGS {
628 let got: BTreeSet<char> = rows
629 .iter()
630 .filter(|r| r.bin == *bin && !SHARED_FLAGS.contains(&r.arg.as_str()))
631 .filter_map(|r| r.short)
632 .collect();
633 let want: BTreeSet<char> = pinned.chars().collect();
634 if got != want {
635 v.push(Violation {
636 rule: "compat".to_string(),
637 subcommand: String::new(),
638 bin: (*bin).to_string(),
639 arg: "short-flags".to_string(),
640 detail: format!("frozen upstream set drift: got {got:?} want {want:?}"),
641 });
642 }
643 }
644
645 typed_missing_help(&rows, &mut v);
646
647 v
648}
649
650fn typed_missing_help(rows: &[Row], out: &mut Vec<Violation>) {
655 out.extend(
656 rows.iter()
657 .filter(|r| {
658 r.kind != "registry" && r.help.is_empty() && r.arg != "help" && r.arg != "version"
659 })
660 .map(|r| Violation {
661 rule: "typed".to_string(),
662 subcommand: r.subcommand.clone(),
663 bin: r.bin.clone(),
664 arg: r.arg.clone(),
665 detail: "argument has no help text".to_string(),
666 }),
667 );
668}
669
670pub fn check_keys(live: &[String], baseline: &[String]) -> anyhow::Result<usize> {
675 let live_set: BTreeSet<&String> = live.iter().collect();
676 let baseline_set: BTreeSet<&String> = baseline.iter().collect();
677 if live_set.len() != live.len() {
678 return Err(anyhow::anyhow!(
679 "grammar check failed: duplicate keys in live set: {live:?}"
680 ));
681 }
682 if baseline_set.len() != baseline.len() {
683 return Err(anyhow::anyhow!(
684 "grammar check failed: duplicate keys in baseline: {baseline:?}"
685 ));
686 }
687 let new: Vec<&String> = live_set.difference(&baseline_set).copied().collect();
688 let stale: Vec<&String> = baseline_set.difference(&live_set).copied().collect();
689 if !new.is_empty() || !stale.is_empty() {
690 return Err(anyhow::anyhow!(
691 "grammar check failed: {new_count} new, {stale_count} stale\nnew: {new:?}\nstale: {stale:?}",
692 new_count = new.len(),
693 stale_count = stale.len()
694 ));
695 }
696 Ok(live.len())
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701pub struct CheckReport {
702 pub ok: bool,
703 pub baseline: usize,
704 pub violations: usize,
705}
706
707pub fn check(baseline: &Path) -> anyhow::Result<CheckReport> {
711 let source = std::fs::read_to_string(baseline).with_context(|| {
712 format!(
713 "grammar check failed: cannot read baseline {}",
714 baseline.display()
715 )
716 })?;
717 let base: Vec<String> = serde_json::from_str(&source)?;
718 let live = violations();
719 let keys: Vec<String> = live.iter().map(key_of).collect();
720 let count = check_keys(&keys, &base)?;
721 Ok(CheckReport {
722 ok: true,
723 baseline: base.len(),
724 violations: count,
725 })
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
735 fn typed_keys_distinguish_same_arg_across_subcommands() {
736 let mk = |sub: &str| Row {
737 bin: "cast".to_string(),
738 subcommand: sub.to_string(),
739 arg: "on".to_string(),
740 kind: "flag".to_string(),
741 short: None,
742 long: None,
743 r#type: String::new(),
744 default: String::new(),
745 help: String::new(),
746 };
747 let mut v = Vec::new();
748 typed_missing_help(&[mk("build"), mk("deploy")], &mut v);
749 let keys: BTreeSet<String> = v.iter().map(key_of).collect();
750 assert_eq!(
751 keys,
752 BTreeSet::from([
753 "typed cast build on".to_string(),
754 "typed cast deploy on".to_string(),
755 ]),
756 "same arg ID on distinct subcommands must produce two distinct typed keys"
757 );
758 }
759
760 #[test]
764 fn global_flags_rejects_missing_and_unexpected_root_longs() {
765 let mut rows = dump();
766 assert!(
767 {
768 let mut v = Vec::new();
769 global_flags(&rows, &mut v);
770 v
771 }
772 .is_empty(),
773 "current tree must satisfy the pinned root long sets"
774 );
775
776 rows.push(Row {
777 bin: "cast".to_string(),
778 subcommand: String::new(),
779 arg: "prefix".to_string(),
780 kind: "switch".to_string(),
781 short: None,
782 long: Some("prefix".to_string()),
783 r#type: String::new(),
784 default: String::new(),
785 help: "synthetic".to_string(),
786 });
787 let keys: BTreeSet<String> = {
788 let mut v = Vec::new();
789 global_flags(&rows, &mut v);
790 v.iter().map(key_of).collect()
791 };
792 assert_eq!(
793 keys,
794 BTreeSet::from(["global-flags cast (root) prefix".to_string()]),
795 "synthetic long-only flag must be rejected as unexpected"
796 );
797
798 for r in rows.iter_mut() {
801 if r.bin == "cast" && r.arg == "format" {
802 r.long = Some("output-format".to_string());
803 }
804 }
805 let keys: BTreeSet<String> = {
806 let mut v = Vec::new();
807 global_flags(&rows, &mut v);
808 v.iter().map(key_of).collect()
809 };
810 assert!(
811 keys.contains("global-flags cast (root) format"),
812 "renamed spelling must violate as missing: {keys:?}"
813 );
814 assert!(
815 keys.contains("global-flags cast (root) output-format"),
816 "renamed spelling must violate as unexpected: {keys:?}"
817 );
818 }
819
820 #[test]
825 fn selector_rule_rejects_unknown_and_missing_selectors() {
826 let mut rows = dump();
827 assert!(
828 {
829 let mut v = Vec::new();
830 selectors(&rows, &mut v);
831 v
832 }
833 .is_empty(),
834 "live dump must satisfy the pinned selector sets"
835 );
836
837 let mut push = |bin: &str, sub: &str, arg: &str| {
838 rows.push(Row {
839 bin: bin.to_string(),
840 subcommand: sub.to_string(),
841 arg: arg.to_string(),
842 kind: "flag".to_string(),
843 short: None,
844 long: Some(arg.to_string()),
845 r#type: String::new(),
846 default: String::new(),
847 help: "synthetic".to_string(),
848 })
849 };
850 push("molt", "", "on");
851 push("cast", "health", "on");
852 push("cast", "eval", "zone");
853 let keys: BTreeSet<String> = {
854 let mut v = Vec::new();
855 selectors(&rows, &mut v);
856 v.iter().map(key_of).collect()
857 };
858 assert_eq!(
859 keys,
860 BTreeSet::from([
861 "selector molt (root) on".to_string(),
862 "selector cast health on".to_string(),
863 "selector cast eval zone".to_string(),
864 ]),
865 "unknown selectors must violate: {keys:?}"
866 );
867
868 rows.retain(|r| !(r.bin == "cast" && r.subcommand == "deploy" && r.arg == "tag"));
869 rows.retain(|r| !(r.bin == "claw" && r.subcommand.is_empty() && r.arg == "nodes"));
870 let keys: BTreeSet<String> = {
871 let mut v = Vec::new();
872 selectors(&rows, &mut v);
873 v.iter().map(key_of).collect()
874 };
875 assert!(
876 keys.contains("selector cast deploy tag"),
877 "removed cast deploy --tag must violate as missing: {keys:?}"
878 );
879 assert!(
880 keys.contains("selector claw (root) nodes"),
881 "removed claw --nodes must violate as missing: {keys:?}"
882 );
883 }
884}