Skip to main content

fallow_types/
command_surfaces.rs

1//! One row per analysis subcommand: the JSON kinds it writes and the surfaces
2//! that must know about it.
3//!
4//! A new analysis command must reach the root `FallowOutput` schema, `fallow
5//! report --from`, the MCP server, the GitHub Action, the GitLab CI template
6//! and the drift harness. Each of those
7//! surfaces has a test that reads
8//! [`COMMAND_ENVELOPES`](crate::command_surfaces::COMMAND_ENVELOPES), so a
9//! command that is added to the CLI without a row, or a row without a
10//! surface, fails a test instead of shipping a silent gap. Every other
11//! visible subcommand is listed in
12//! [`COMMANDS_WITHOUT_ANALYSIS_ENVELOPE`](crate::command_surfaces::COMMANDS_WITHOUT_ANALYSIS_ENVELOPE)
13//! with the reason it has no row.
14
15/// How `fallow report --from` treats a saved envelope of a command.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReportFrom {
18    /// `fallow report --from` renders the saved envelope, flat and grouped.
19    Renders,
20    /// `fallow report --from` refuses the saved envelope, for this reason.
21    Refused(&'static str),
22}
23
24/// How the GitHub Action and the GitLab CI template treat a command.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CiIntegration {
27    /// The `command` input of the Action and `FALLOW_COMMAND` of the GitLab
28    /// template accept the command, and the job summary routes its envelope.
29    Routed,
30    /// The CI integrations do not run the command, for this reason.
31    Omitted(&'static str),
32}
33
34/// The machine contract of one analysis subcommand.
35#[derive(Debug, Clone, Copy)]
36pub struct CommandEnvelope {
37    /// The subcommand, as `fallow <command>` spells it.
38    pub command: &'static str,
39    /// The root `kind` of `fallow <command> --format json`.
40    pub kind: &'static str,
41    /// The root `kind` with `--group-by`, when grouping writes another kind.
42    pub grouped_kind: Option<&'static str>,
43    /// How `fallow report --from` treats a saved envelope.
44    pub report_from: ReportFrom,
45    /// The MCP tool whose CLI analogue is this command.
46    pub mcp_tool: Option<&'static str>,
47    /// Why no MCP tool exists. `Some` exactly when `mcp_tool` is `None`.
48    pub mcp_omission: Option<&'static str>,
49    /// Whether the drift harness compares the verdict of the JSON and human
50    /// runs (invariant I7).
51    pub verdict: bool,
52    /// How the GitHub Action and the GitLab CI template treat the command.
53    pub ci: CiIntegration,
54}
55
56/// Every analysis subcommand with its JSON kinds and surfaces.
57pub const COMMAND_ENVELOPES: &[CommandEnvelope] = &[
58    CommandEnvelope {
59        command: "dead-code",
60        kind: "dead-code",
61        grouped_kind: Some("dead-code-grouped"),
62        report_from: ReportFrom::Renders,
63        mcp_tool: Some("analyze"),
64        mcp_omission: None,
65        verdict: true,
66        ci: CiIntegration::Routed,
67    },
68    CommandEnvelope {
69        command: "architecture",
70        kind: "architecture",
71        grouped_kind: Some("architecture-grouped"),
72        report_from: ReportFrom::Renders,
73        mcp_tool: Some("check_architecture"),
74        mcp_omission: None,
75        verdict: true,
76        ci: CiIntegration::Routed,
77    },
78    CommandEnvelope {
79        command: "dupes",
80        kind: "dupes",
81        grouped_kind: None,
82        report_from: ReportFrom::Renders,
83        mcp_tool: Some("find_dupes"),
84        mcp_omission: None,
85        verdict: true,
86        ci: CiIntegration::Routed,
87    },
88    CommandEnvelope {
89        command: "health",
90        kind: "health",
91        grouped_kind: None,
92        report_from: ReportFrom::Renders,
93        mcp_tool: Some("check_health"),
94        mcp_omission: None,
95        verdict: true,
96        ci: CiIntegration::Routed,
97    },
98    CommandEnvelope {
99        command: "security",
100        kind: "security",
101        grouped_kind: None,
102        report_from: ReportFrom::Renders,
103        mcp_tool: Some("security_candidates"),
104        mcp_omission: None,
105        verdict: true,
106        ci: CiIntegration::Routed,
107    },
108    CommandEnvelope {
109        command: "audit",
110        kind: "audit",
111        grouped_kind: None,
112        report_from: ReportFrom::Renders,
113        mcp_tool: Some("audit"),
114        mcp_omission: None,
115        verdict: true,
116        ci: CiIntegration::Routed,
117    },
118    CommandEnvelope {
119        command: "flags",
120        kind: "feature-flags",
121        grouped_kind: None,
122        report_from: ReportFrom::Refused(
123            "feature flags are an inventory with no CI annotation or review surface",
124        ),
125        mcp_tool: Some("feature_flags"),
126        mcp_omission: None,
127        verdict: false,
128        ci: CiIntegration::Omitted("feature flags are an inventory with no CI gate or annotation"),
129    },
130    CommandEnvelope {
131        command: "similar-code",
132        kind: "similar-code",
133        grouped_kind: None,
134        report_from: ReportFrom::Refused(
135            "similar-code candidates are unverified and never feed a CI surface",
136        ),
137        mcp_tool: Some("find_similar_code"),
138        mcp_omission: None,
139        verdict: false,
140        ci: CiIntegration::Omitted(
141            "similar-code candidates are unverified and never feed a CI surface",
142        ),
143    },
144];
145
146/// Visible subcommands that write no analysis report, with the reason.
147pub const COMMANDS_WITHOUT_ANALYSIS_ENVELOPE: &[(&str, &str)] = &[
148    (
149        "guard",
150        "per-file rule lookup before an edit; it reports rules, not findings",
151    ),
152    ("watch", "interactive loop that prints human output only"),
153    (
154        "fix",
155        "applies fixes; `report --from` detects its kind-less envelope by its fields",
156    ),
157    (
158        "list",
159        "project inspection; writes `list-boundaries` or `list-workspaces`",
160    ),
161    ("inspect", "evidence bundle for one target"),
162    ("trace", "call-chain and import-path trace for one symbol"),
163    ("trace-error", "stack-trace frame resolution"),
164    (
165        "decision-surface",
166        "advisory review signals of a change, not findings",
167    ),
168    ("workspaces", "workspace discovery diagnostics"),
169    ("explain", "static issue-type documentation"),
170    ("suppressions", "inventory of suppression markers"),
171    ("impact", "local impact digest of fallow itself"),
172    ("viz", "writes an HTML map"),
173    ("doctor", "project readiness checks"),
174    ("init", "writes a config file"),
175    ("agent", "wires fallow into agent tools"),
176    ("audit-cache", "maintains audit base-snapshot caches"),
177    (
178        "baselines",
179        "maintains committed baseline files; writes a `baselines-prune` envelope",
180    ),
181    ("recommend", "config recommendation for an agent"),
182    ("migrate", "config migration"),
183    ("config", "resolved config"),
184    ("config-schema", "JSON Schema of the config"),
185    ("plugin-schema", "JSON Schema of external plugins"),
186    ("plugin-check", "external plugin dry run"),
187    ("rule-pack", "rule-pack management"),
188    ("rule-pack-schema", "JSON Schema of rule packs"),
189    ("type-aware", "semantic companion status"),
190    ("ci", "builds PR and MR feedback from a saved envelope"),
191    ("ci-template", "CI template output"),
192    ("report", "renders a saved envelope"),
193    ("hooks", "Git and agent hook management"),
194    ("setup-hooks", "deprecated alias of hook installation"),
195    ("coverage", "runtime coverage setup and analysis"),
196    ("license", "license management"),
197    ("telemetry", "telemetry settings"),
198    ("schema", "capability manifest"),
199];
200
201/// The row of `command`, when it is an analysis subcommand.
202#[must_use]
203pub fn command_envelope(command: &str) -> Option<&'static CommandEnvelope> {
204    COMMAND_ENVELOPES.iter().find(|row| row.command == command)
205}
206
207#[cfg(test)]
208mod tests {
209    use std::collections::BTreeSet;
210
211    use super::*;
212
213    #[test]
214    fn rows_name_each_command_and_kind_once() {
215        let commands: BTreeSet<&str> = COMMAND_ENVELOPES.iter().map(|row| row.command).collect();
216        assert_eq!(commands.len(), COMMAND_ENVELOPES.len());
217        let kinds: Vec<&str> = COMMAND_ENVELOPES
218            .iter()
219            .flat_map(|row| std::iter::once(row.kind).chain(row.grouped_kind))
220            .collect();
221        let unique: BTreeSet<&str> = kinds.iter().copied().collect();
222        assert_eq!(unique.len(), kinds.len(), "a kind is in two rows");
223        for (command, _) in COMMANDS_WITHOUT_ANALYSIS_ENVELOPE {
224            assert!(
225                command_envelope(command).is_none(),
226                "{command} is in both lists"
227            );
228        }
229    }
230
231    #[test]
232    fn every_row_has_an_mcp_tool_or_a_reason() {
233        for row in COMMAND_ENVELOPES {
234            assert_eq!(
235                row.mcp_tool.is_some(),
236                row.mcp_omission.is_none(),
237                "{}: set exactly one of mcp_tool and mcp_omission",
238                row.command
239            );
240            if let Some(tool) = row.mcp_tool {
241                let info = crate::mcp_manifest::MCP_TOOLS
242                    .iter()
243                    .find(|info| info.name == tool)
244                    .unwrap_or_else(|| panic!("{}: no MCP tool `{tool}`", row.command));
245                let expected = format!("fallow {} ", row.command);
246                assert!(
247                    info.cli_command
248                        .is_some_and(|cli| cli.starts_with(expected.as_str())),
249                    "{}: MCP tool `{tool}` names another CLI analogue: {:?}",
250                    row.command,
251                    info.cli_command
252                );
253            }
254        }
255    }
256}