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: "migrate a dependency",
78        command: "fallow trace --dependency <name> --sites",
79        note: Some(
80            "Counts each imported name, follows one hop through project wrappers, and counts each use that it cannot resolve.",
81        ),
82        probe: &["trace", "--dependency", "lodash", "--sites"],
83    },
84    TaskRow {
85        task: "commit or open a PR",
86        command: "fallow audit --base <ref>",
87        note: None,
88        probe: &["audit", "--base", "main"],
89    },
90    TaskRow {
91        task: "read a diff before approving it",
92        command: "fallow review --base <ref> --brief",
93        note: Some(
94            "orientation, never gates: deterministic and always exit 0, unlike the audit row",
95        ),
96        probe: &["review", "--base", "main", "--brief"],
97    },
98    TaskRow {
99        task: "prioritize refactoring",
100        command: "fallow health --hotspots --targets",
101        note: None,
102        probe: &["health", "--hotspots", "--targets"],
103    },
104    TaskRow {
105        task: "ask who owns code",
106        command: "fallow health --ownership",
107        note: None,
108        probe: &["health", "--ownership"],
109    },
110    TaskRow {
111        task: "check untested-but-reachable code",
112        command: "fallow health --coverage-gaps",
113        note: None,
114        probe: &["health", "--coverage-gaps"],
115    },
116    TaskRow {
117        task: "consolidate duplication",
118        command: "fallow dupes --trace dup:<fingerprint>",
119        note: None,
120        probe: &["dupes", "--trace", "dup:abc123"],
121    },
122    TaskRow {
123        task: "find feature flags",
124        command: "fallow flags",
125        note: None,
126        probe: &["flags"],
127    },
128    TaskRow {
129        task: "check which architecture rules apply to a file before changing it",
130        command: "fallow guard <files>",
131        note: None,
132        probe: &["guard", "src/index.ts"],
133    },
134    TaskRow {
135        task: "check import cycles, boundaries and policy rules after changing code",
136        command: "fallow architecture",
137        note: None,
138        probe: &["architecture"],
139    },
140    TaskRow {
141        task: "surface security candidates",
142        command: "fallow security",
143        note: None,
144        probe: &["security"],
145    },
146    TaskRow {
147        task: "understand a finding",
148        command: "fallow explain <issue-type>",
149        note: None,
150        probe: &["explain", "unused-export"],
151    },
152    TaskRow {
153        task: "scope a monorepo",
154        command: "--workspace <glob> / --changed-workspaces <ref>",
155        note: Some("global flags, prefix any command"),
156        // Flag-fragment row: no leading subcommand. Covered by
157        // `task_matrix_workspace_flags_are_global` in the CLI schema tests.
158        probe: &[],
159    },
160];
161
162impl TaskRow {
163    /// The `fallow schema` `task_matrix` row: `task`, `command`, and `note`
164    /// (`null` when absent, honoring the manifest's no-absent-key convention).
165    /// `probe` is a test-only concern and never serializes.
166    #[must_use]
167    pub fn to_json(&self) -> serde_json::Value {
168        serde_json::json!({
169            "task": self.task,
170            "command": self.command,
171            "note": self.note,
172        })
173    }
174}
175
176/// Mutating command tokens the matrix must never reference (R1 read-only
177/// principle). Shared with the CLI schema exclusion test.
178pub const MUTATING_COMMANDS: &[&str] = &[
179    "agent",
180    "fix",
181    "init",
182    "hooks",
183    "migrate",
184    "setup-hooks",
185    "watch",
186];
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    /// The first command token after the `fallow` prefix, or the empty string
193    /// for a bare flag-fragment row.
194    fn leading_command_token(row: &TaskRow) -> &'static str {
195        let after_fallow = row.command.strip_prefix("fallow ").unwrap_or(row.command);
196        after_fallow.split_whitespace().next().unwrap_or("")
197    }
198
199    #[test]
200    fn matrix_is_non_empty() {
201        assert!(!TASK_MATRIX.is_empty());
202    }
203
204    /// Read-only-evidence contract (R1): no row may name a mutating command.
205    #[test]
206    fn matrix_excludes_mutating_commands() {
207        for row in TASK_MATRIX {
208            let first_token = leading_command_token(row);
209            assert!(
210                !MUTATING_COMMANDS.contains(&first_token),
211                "task matrix row '{}' names mutating command '{first_token}'",
212                row.task
213            );
214        }
215    }
216
217    #[test]
218    fn to_json_omits_probe_and_keeps_note_key() {
219        let row = TaskRow {
220            task: "t",
221            command: "fallow flags",
222            note: None,
223            probe: &["flags"],
224        };
225        let value = row.to_json();
226        assert_eq!(value["task"], "t");
227        assert_eq!(value["command"], "fallow flags");
228        assert!(value["note"].is_null());
229        assert!(value.get("probe").is_none());
230    }
231
232    #[test]
233    fn tasks_are_unique() {
234        let mut tasks: Vec<&str> = TASK_MATRIX.iter().map(|row| row.task).collect();
235        let total = tasks.len();
236        tasks.sort_unstable();
237        tasks.dedup();
238        assert_eq!(tasks.len(), total, "duplicate task in TASK_MATRIX");
239    }
240
241    #[test]
242    fn leading_token_skips_the_fallow_prefix() {
243        let row = TaskRow {
244            task: "t",
245            command: "fallow audit --base main",
246            note: None,
247            probe: &[],
248        };
249        assert_eq!(leading_command_token(&row), "audit");
250        let fragment = TaskRow {
251            task: "t",
252            command: "--workspace <glob>",
253            note: None,
254            probe: &[],
255        };
256        assert_eq!(leading_command_token(&fragment), "--workspace");
257    }
258}