Skip to main content

fallow_types/
task_matrix.rs

1//! Single source of truth for the agent-discoverability task-to-command matrix
2//! (R2/R3). One const slice drives every render surface: the `fallow schema`
3//! manifest (`task_matrix`), the `init --agents` AGENTS.md template, the
4//! `hooks install --target agent` managed block, the root `--help` cheat
5//! sheet, and the `fallow://task-matrix` MCP resource. The
6//! `scripts/generate-agent-docs.mjs` generator renders the same table into
7//! SKILL.md from the schema-serialized form, so the Markdown surfaces stay
8//! consistent without duplicating the rows.
9//!
10//! This module carries data only. The Markdown renderer and the clap probe
11//! drift test live in `crates/cli`, which owns the command tree; the MCP
12//! server projects the rows without `probe`.
13//!
14//! Read-only-evidence principle (R1): the matrix carries NO mutating commands
15//! (`fix`, `init`, `hooks`, `migrate`, `setup-hooks`, `watch`). Unit tests in
16//! this crate and in `crates/cli` pin that contract, mirroring the
17//! `next_steps[]` builder in the CLI report layer.
18
19/// One task-to-command row for the agent-discoverability cheat sheet (R2/R3).
20///
21/// `command` MAY contain `<placeholder>` or glob tokens because it renders
22/// into docs and help text, unlike the runnable-only `next_steps[]` contract.
23/// `probe` is the runnable clap token sequence (placeholders and values
24/// replaced with concrete dummies) that the CLI schema drift test parses
25/// through `Cli::try_parse_from`, so a row can never name a flag or subcommand
26/// that does not exist. A row whose command is a bare flag fragment (no
27/// leading subcommand) carries an empty `probe`; the drift test skips it and a
28/// dedicated test asserts the flags exist on the live global arg set instead.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct TaskRow {
31    /// The agent intent, phrased as "when the agent is about to ...".
32    pub task: &'static str,
33    /// The command to run, render-ready (may contain `<placeholder>` tokens).
34    pub command: &'static str,
35    /// Optional clarifying note appended in parentheses in the rendered table.
36    pub note: Option<&'static str>,
37    /// Runnable clap token sequence the CLI drift test parses, or empty for a
38    /// flag-fragment row that is covered by the global-flag existence test.
39    pub probe: &'static [&'static str],
40}
41
42/// The canonical task-to-command matrix. Verified against the live clap
43/// command tree; the CLI schema drift test re-checks every non-empty `probe`.
44pub const TASK_MATRIX: &[TaskRow] = &[
45    TaskRow {
46        task: "delete an \"unused\" export or file",
47        command: "fallow dead-code --trace <file>:<export>",
48        note: None,
49        probe: &["dead-code", "--trace", "src/index.ts:foo"],
50    },
51    TaskRow {
52        task: "prove a TypeScript symbol's exact consumers before refactoring",
53        command: "fallow dead-code --type-aware --symbol-impact <file>:<export-or-class.method>",
54        note: None,
55        probe: &[
56            "dead-code",
57            "--type-aware",
58            "--symbol-impact",
59            "src/index.ts:foo",
60        ],
61    },
62    TaskRow {
63        task: "find how one module reaches another",
64        command: "fallow trace --path <from> <to>",
65        note: Some(
66            "Reports `reachable: false` instead of failing when no import path exists; type-only hops are reported, not skipped.",
67        ),
68        probe: &["trace", "--path", "src/app.ts", "src/db.ts"],
69    },
70    TaskRow {
71        task: "delete an \"unused\" dependency",
72        command: "fallow dead-code --trace-dependency <name>",
73        note: None,
74        probe: &["dead-code", "--trace-dependency", "lodash"],
75    },
76    TaskRow {
77        task: "commit or open a PR",
78        command: "fallow audit --base <ref>",
79        note: None,
80        probe: &["audit", "--base", "main"],
81    },
82    TaskRow {
83        task: "read a diff before approving it",
84        command: "fallow review --base <ref> --brief",
85        note: Some(
86            "orientation, never gates: deterministic and always exit 0, unlike the audit row",
87        ),
88        probe: &["review", "--base", "main", "--brief"],
89    },
90    TaskRow {
91        task: "prioritize refactoring",
92        command: "fallow health --hotspots --targets",
93        note: None,
94        probe: &["health", "--hotspots", "--targets"],
95    },
96    TaskRow {
97        task: "ask who owns code",
98        command: "fallow health --ownership",
99        note: None,
100        probe: &["health", "--ownership"],
101    },
102    TaskRow {
103        task: "check untested-but-reachable code",
104        command: "fallow health --coverage-gaps",
105        note: None,
106        probe: &["health", "--coverage-gaps"],
107    },
108    TaskRow {
109        task: "consolidate duplication",
110        command: "fallow dupes --trace dup:<fingerprint>",
111        note: None,
112        probe: &["dupes", "--trace", "dup:abc123"],
113    },
114    TaskRow {
115        task: "find feature flags",
116        command: "fallow flags",
117        note: None,
118        probe: &["flags"],
119    },
120    TaskRow {
121        task: "check which architecture rules apply to a file before changing it",
122        command: "fallow guard <files>",
123        note: None,
124        probe: &["guard", "src/index.ts"],
125    },
126    TaskRow {
127        task: "surface security candidates",
128        command: "fallow security",
129        note: None,
130        probe: &["security"],
131    },
132    TaskRow {
133        task: "understand a finding",
134        command: "fallow explain <issue-type>",
135        note: None,
136        probe: &["explain", "unused-export"],
137    },
138    TaskRow {
139        task: "scope a monorepo",
140        command: "--workspace <glob> / --changed-workspaces <ref>",
141        note: Some("global flags, prefix any command"),
142        // Flag-fragment row: no leading subcommand. Covered by
143        // `task_matrix_workspace_flags_are_global` in the CLI schema tests.
144        probe: &[],
145    },
146];
147
148impl TaskRow {
149    /// The `fallow schema` `task_matrix` row: `task`, `command`, and `note`
150    /// (`null` when absent, honoring the manifest's no-absent-key convention).
151    /// `probe` is a test-only concern and never serializes.
152    #[must_use]
153    pub fn to_json(&self) -> serde_json::Value {
154        serde_json::json!({
155            "task": self.task,
156            "command": self.command,
157            "note": self.note,
158        })
159    }
160}
161
162/// Mutating command tokens the matrix must never reference (R1 read-only
163/// principle). Shared with the CLI schema exclusion test.
164pub const MUTATING_COMMANDS: &[&str] = &[
165    "agent",
166    "fix",
167    "init",
168    "hooks",
169    "migrate",
170    "setup-hooks",
171    "watch",
172];
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// The first command token after the `fallow` prefix, or the empty string
179    /// for a bare flag-fragment row.
180    fn leading_command_token(row: &TaskRow) -> &'static str {
181        let after_fallow = row.command.strip_prefix("fallow ").unwrap_or(row.command);
182        after_fallow.split_whitespace().next().unwrap_or("")
183    }
184
185    #[test]
186    fn matrix_is_non_empty() {
187        assert!(!TASK_MATRIX.is_empty());
188    }
189
190    /// Read-only-evidence contract (R1): no row may name a mutating command.
191    #[test]
192    fn matrix_excludes_mutating_commands() {
193        for row in TASK_MATRIX {
194            let first_token = leading_command_token(row);
195            assert!(
196                !MUTATING_COMMANDS.contains(&first_token),
197                "task matrix row '{}' names mutating command '{first_token}'",
198                row.task
199            );
200        }
201    }
202
203    #[test]
204    fn to_json_omits_probe_and_keeps_note_key() {
205        let row = TaskRow {
206            task: "t",
207            command: "fallow flags",
208            note: None,
209            probe: &["flags"],
210        };
211        let value = row.to_json();
212        assert_eq!(value["task"], "t");
213        assert_eq!(value["command"], "fallow flags");
214        assert!(value["note"].is_null());
215        assert!(value.get("probe").is_none());
216    }
217
218    #[test]
219    fn tasks_are_unique() {
220        let mut tasks: Vec<&str> = TASK_MATRIX.iter().map(|row| row.task).collect();
221        let total = tasks.len();
222        tasks.sort_unstable();
223        tasks.dedup();
224        assert_eq!(tasks.len(), total, "duplicate task in TASK_MATRIX");
225    }
226
227    #[test]
228    fn leading_token_skips_the_fallow_prefix() {
229        let row = TaskRow {
230            task: "t",
231            command: "fallow audit --base main",
232            note: None,
233            probe: &[],
234        };
235        assert_eq!(leading_command_token(&row), "audit");
236        let fragment = TaskRow {
237            task: "t",
238            command: "--workspace <glob>",
239            note: None,
240            probe: &[],
241        };
242        assert_eq!(leading_command_token(&fragment), "--workspace");
243    }
244}