Skip to main content

memstead_cli/
coverage.rs

1//! The CLI's axis-coverage registry: every subcommand a caller can
2//! reach declares either which axes its clean verdict examined or why
3//! it emits no verdict at all. The rule, its vocabulary, and the
4//! validator live in `memstead_base::ops::coverage`; this module is
5//! the CLI consumer's declaration, and the test at the bottom is the
6//! gate: it walks the live clap tree, so a new subcommand fails here
7//! until it declares, and a new axis fails every verdict row that
8//! has not met it.
9
10use memstead_base::ops::coverage::{AxisCoverage, CoverageDisposition, SurfaceCoverage};
11
12// Shared no-verdict reasons. One string per surface family, because
13// the reason is the same fact each time; the rows stay one per
14// surface so a departed subcommand fails as its own stale entry.
15const READS_DATA: &str = "returns data, not a verdict; an empty result is an empty \
16     result, never an all-clear";
17const MUTATION: &str = "mutation surface: reports what it did, never an all-clear \
18     over unexamined state";
19#[cfg(feature = "mem-repo")]
20const TRANSPORT: &str = "transport operation: reports the transfer's own outcome";
21const ACCOUNT_OP: &str = "registry or account operation: reports the operation's own \
22     outcome";
23
24// Shared exclusion reasons for the verdict rows.
25const STATUS_SCOPE: &str = "outside the status rollup, whose verdict answers for \
26     declared projection bindings only";
27const ANCHORS_ONLY: &str = "the standalone anchor statement answers for anchors \
28     alone; it examines no other axis and says so";
29const VERIFY_SCOPE: &str = "the binding-scoped fidelity report answers for one \
30     binding's projection and its anchors; other axes belong to health";
31#[cfg(feature = "mem-repo")]
32const DUMP_SCOPE: &str = "the dump is a configuration and roster snapshot; graph \
33     axes belong to health";
34
35/// `memstead health` under `--strict`: exit 0 is the clean verdict,
36/// and it answers exactly for the promoted set, always-on
37/// configuration and mount axes plus the include-gated promotions.
38/// Everything advisory by the strict contract is excluded by name.
39pub const HEALTH: SurfaceCoverage = SurfaceCoverage {
40    surface: "health",
41    // Shared content (memstead_base::ops::coverage::HEALTH_COVERAGE):
42    // the same declaration the MCP composer stamps, so the CLI's
43    // strict verdict and the composed report cannot diverge.
44    disposition: CoverageDisposition::Verdict(memstead_base::ops::coverage::HEALTH_COVERAGE),
45};
46
47pub const STATUS: SurfaceCoverage = SurfaceCoverage {
48    surface: "status",
49    disposition: CoverageDisposition::Verdict(AxisCoverage {
50        examined: &["projection"],
51        advisory: &[],
52        not_examined: &[
53            ("orphans", STATUS_SCOPE),
54            ("stubs", STATUS_SCOPE),
55            ("most_connected", STATUS_SCOPE),
56            ("missing_fields", STATUS_SCOPE),
57            ("stale", STATUS_SCOPE),
58            ("dangling_links", STATUS_SCOPE),
59            ("tags", STATUS_SCOPE),
60            ("missing_required_outgoing", STATUS_SCOPE),
61            ("constraints", STATUS_SCOPE),
62            ("signals", STATUS_SCOPE),
63            ("labelling", STATUS_SCOPE),
64            ("conformance", STATUS_SCOPE),
65            ("integrity", STATUS_SCOPE),
66            ("config", STATUS_SCOPE),
67            ("anchors", STATUS_SCOPE),
68            ("friction", STATUS_SCOPE),
69            ("open_questions", STATUS_SCOPE),
70            ("vital_signs", STATUS_SCOPE),
71            ("stale_derivations", STATUS_SCOPE),
72            ("checks", STATUS_SCOPE),
73            ("ledger", STATUS_SCOPE),
74            ("mounts", STATUS_SCOPE),
75        ],
76    }),
77};
78
79const OVERVIEW: SurfaceCoverage = SurfaceCoverage {
80    surface: "overview",
81    // The content is the shared constant the composer itself stamps
82    // into the overview frontmatter, so registry and output cannot
83    // diverge.
84    disposition: CoverageDisposition::Verdict(memstead_base::ops::coverage::OVERVIEW_COVERAGE),
85};
86
87pub const VERIFY_ANCHORS: SurfaceCoverage = SurfaceCoverage {
88    surface: "verify-anchors",
89    disposition: CoverageDisposition::Verdict(AxisCoverage {
90        examined: &["anchors"],
91        advisory: &[],
92        not_examined: &[
93            ("orphans", ANCHORS_ONLY),
94            ("stubs", ANCHORS_ONLY),
95            ("most_connected", ANCHORS_ONLY),
96            ("missing_fields", ANCHORS_ONLY),
97            ("stale", ANCHORS_ONLY),
98            ("dangling_links", ANCHORS_ONLY),
99            ("tags", ANCHORS_ONLY),
100            ("missing_required_outgoing", ANCHORS_ONLY),
101            ("constraints", ANCHORS_ONLY),
102            ("signals", ANCHORS_ONLY),
103            ("labelling", ANCHORS_ONLY),
104            ("conformance", ANCHORS_ONLY),
105            ("integrity", ANCHORS_ONLY),
106            ("config", ANCHORS_ONLY),
107            ("friction", ANCHORS_ONLY),
108            ("open_questions", ANCHORS_ONLY),
109            ("vital_signs", ANCHORS_ONLY),
110            ("stale_derivations", ANCHORS_ONLY),
111            ("checks", ANCHORS_ONLY),
112            ("ledger", ANCHORS_ONLY),
113            ("projection", ANCHORS_ONLY),
114            ("mounts", ANCHORS_ONLY),
115        ],
116    }),
117};
118
119pub const PROJECTION_VERIFY: SurfaceCoverage = SurfaceCoverage {
120    surface: "projection verify",
121    disposition: CoverageDisposition::Verdict(AxisCoverage {
122        examined: &["projection", "anchors"],
123        advisory: &[],
124        not_examined: &[
125            ("orphans", VERIFY_SCOPE),
126            ("stubs", VERIFY_SCOPE),
127            ("most_connected", VERIFY_SCOPE),
128            ("missing_fields", VERIFY_SCOPE),
129            ("stale", VERIFY_SCOPE),
130            ("dangling_links", VERIFY_SCOPE),
131            ("tags", VERIFY_SCOPE),
132            ("missing_required_outgoing", VERIFY_SCOPE),
133            ("constraints", VERIFY_SCOPE),
134            ("signals", VERIFY_SCOPE),
135            ("labelling", VERIFY_SCOPE),
136            ("conformance", VERIFY_SCOPE),
137            ("integrity", VERIFY_SCOPE),
138            ("config", VERIFY_SCOPE),
139            ("friction", VERIFY_SCOPE),
140            ("open_questions", VERIFY_SCOPE),
141            ("vital_signs", VERIFY_SCOPE),
142            ("stale_derivations", VERIFY_SCOPE),
143            ("checks", VERIFY_SCOPE),
144            ("ledger", VERIFY_SCOPE),
145            ("mounts", VERIFY_SCOPE),
146        ],
147    }),
148};
149
150#[cfg(feature = "mem-repo")]
151pub const WORKSPACE_DUMP: SurfaceCoverage = SurfaceCoverage {
152    surface: "workspace dump",
153    disposition: CoverageDisposition::Verdict(AxisCoverage {
154        examined: &["mounts", "config"],
155        advisory: &[],
156        not_examined: &[
157            ("orphans", DUMP_SCOPE),
158            ("stubs", DUMP_SCOPE),
159            ("most_connected", DUMP_SCOPE),
160            ("missing_fields", DUMP_SCOPE),
161            ("stale", DUMP_SCOPE),
162            ("dangling_links", DUMP_SCOPE),
163            ("tags", DUMP_SCOPE),
164            ("missing_required_outgoing", DUMP_SCOPE),
165            ("constraints", DUMP_SCOPE),
166            ("signals", DUMP_SCOPE),
167            ("labelling", DUMP_SCOPE),
168            ("conformance", DUMP_SCOPE),
169            ("integrity", DUMP_SCOPE),
170            ("anchors", DUMP_SCOPE),
171            ("friction", DUMP_SCOPE),
172            ("open_questions", DUMP_SCOPE),
173            ("vital_signs", DUMP_SCOPE),
174            ("stale_derivations", DUMP_SCOPE),
175            ("checks", DUMP_SCOPE),
176            ("ledger", DUMP_SCOPE),
177            (
178                "projection",
179                "binding fidelity is answered by status and projection verify",
180            ),
181        ],
182    }),
183};
184
185fn no_verdict(surface: &'static str, reason: &'static str) -> SurfaceCoverage {
186    SurfaceCoverage {
187        surface,
188        disposition: CoverageDisposition::NoVerdict(reason),
189    }
190}
191
192/// Every CLI surface's coverage row. Names are the clap path exactly
193/// as the walk below produces it ("workspace dump", not "dump").
194/// Feature-gated commands carry the same gate as their clap variant,
195/// so the lean build's registry matches the lean build's walk.
196pub fn surface_registry() -> Vec<SurfaceCoverage> {
197    #[cfg_attr(not(feature = "mem-repo"), allow(unused_mut))]
198    let mut rows = vec![
199        STATUS,
200        HEALTH,
201        OVERVIEW,
202        VERIFY_ANCHORS,
203        PROJECTION_VERIFY,
204        // Read surfaces that return data rather than a verdict.
205        no_verdict("entity", READS_DATA),
206        no_verdict("relations", READS_DATA),
207        no_verdict("search", READS_DATA),
208        no_verdict("list", READS_DATA),
209        no_verdict("context", READS_DATA),
210        no_verdict("type", READS_DATA),
211        no_verdict("due", READS_DATA),
212        no_verdict("gates", READS_DATA),
213        no_verdict("export", READS_DATA),
214        no_verdict("changes", READS_DATA),
215        no_verdict("anchors", READS_DATA),
216        no_verdict("conflicts list", READS_DATA),
217        no_verdict("review-mark list", READS_DATA),
218        no_verdict("review-mark diff", READS_DATA),
219        no_verdict("projection brief", READS_DATA),
220        no_verdict("projection check-path", READS_DATA),
221        // The check ledger: the one surface deliberately outside the
222        // rule, because its verdict is the caller's claim about the
223        // caller's own work, never the engine's claim about state the
224        // engine examined.
225        no_verdict(
226            "check",
227            "records the caller's verdict about the caller's own work into the \
228             append-only ledger; the engine derives no verdict of its own",
229        ),
230        // Schema tooling verdicts are total over the caller-named
231        // input, so no workspace axis is claimed.
232        no_verdict(
233            "schema validate",
234            "validates the caller-named schema package; the verdict is total over \
235             exactly that input and claims no workspace axis",
236        ),
237        no_verdict("schema new", MUTATION),
238        no_verdict("schema install", MUTATION),
239        no_verdict(
240            "schema migrate",
241            "previews or applies rewrites of the caller-named schema package; reports \
242             the rewrites, never an all-clear over any workspace axis",
243        ),
244        // Mutations and setup.
245        no_verdict("create", MUTATION),
246        no_verdict("update", MUTATION),
247        no_verdict("relate", MUTATION),
248        no_verdict("delete", MUTATION),
249        no_verdict("rename", MUTATION),
250        no_verdict("retype", MUTATION),
251        no_verdict("conflicts resolve", MUTATION),
252        no_verdict("review-mark set", MUTATION),
253        no_verdict("review-mark clear", MUTATION),
254        no_verdict("reload", MUTATION),
255        no_verdict("init", MUTATION),
256        no_verdict("quickstart", MUTATION),
257        no_verdict("projection init", MUTATION),
258        no_verdict("projection migrate", MUTATION),
259        no_verdict("projection enable", MUTATION),
260        no_verdict("projection edit", MUTATION),
261        no_verdict("projection advance", MUTATION),
262        no_verdict("projection exclude", MUTATION),
263        // Registry and account operations.
264        no_verdict("publish", ACCOUNT_OP),
265        no_verdict("unpublish", ACCOUNT_OP),
266        no_verdict("login", ACCOUNT_OP),
267        no_verdict("logout", ACCOUNT_OP),
268        no_verdict("domain keygen", ACCOUNT_OP),
269        no_verdict("domain manifest", ACCOUNT_OP),
270        no_verdict("admin takedown", ACCOUNT_OP),
271        no_verdict("admin denylist", ACCOUNT_OP),
272    ];
273    #[cfg(feature = "mem-repo")]
274    rows.extend([
275        WORKSPACE_DUMP,
276        no_verdict("install", MUTATION),
277        no_verdict("uninstall", MUTATION),
278        no_verdict("batch-update", MUTATION),
279        no_verdict("batch-create", MUTATION),
280        no_verdict("batch-relate", MUTATION),
281        no_verdict("recover", MUTATION),
282        no_verdict("fetch", TRANSPORT),
283        no_verdict("pull", TRANSPORT),
284        no_verdict("push", TRANSPORT),
285        no_verdict("branch-reset", TRANSPORT),
286        no_verdict("mem init", MUTATION),
287        no_verdict("mem unregister", MUTATION),
288        no_verdict("mem delete", MUTATION),
289        no_verdict("mem rename", MUTATION),
290        no_verdict("mem set-version", MUTATION),
291        no_verdict("mem set-schema", MUTATION),
292        no_verdict("mem set-description", MUTATION),
293        no_verdict("mem set-title", MUTATION),
294        no_verdict("mem set-subject", MUTATION),
295        no_verdict("mem set-sync-state", MUTATION),
296        no_verdict("mem set-internal", MUTATION),
297        no_verdict("mem list", READS_DATA),
298        no_verdict("mem-repo init", MUTATION),
299        no_verdict("mem-repo remote-add", MUTATION),
300        no_verdict("workspace show", READS_DATA),
301        no_verdict("workspace allow-create", MUTATION),
302        no_verdict("workspace revoke-create", MUTATION),
303        no_verdict("workspace allow-delete", MUTATION),
304        no_verdict("workspace revoke-delete", MUTATION),
305        no_verdict("workspace grant-cross-link", MUTATION),
306        no_verdict("workspace revoke-cross-link", MUTATION),
307        no_verdict("workspace set-mutations", MUTATION),
308    ]);
309    rows
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use clap::CommandFactory;
316    use memstead_base::ops::coverage::{validate_coverage, verdict_axes};
317
318    /// Walk the live clap tree to its leaves. This is the discovery
319    /// half of the gate: the roster comes from the binary's own
320    /// command definition, never from a hand-kept list, so a
321    /// subcommand cannot land outside the registry's sight.
322    fn discovered_surfaces() -> Vec<String> {
323        fn walk(cmd: &clap::Command, prefix: &str, out: &mut Vec<String>) {
324            let mut leaves = 0;
325            for sub in cmd.get_subcommands() {
326                if sub.get_name() == "help" {
327                    continue;
328                }
329                leaves += 1;
330                let path = if prefix.is_empty() {
331                    sub.get_name().to_string()
332                } else {
333                    format!("{prefix} {}", sub.get_name())
334                };
335                walk(sub, &path, out);
336            }
337            if leaves == 0 && !prefix.is_empty() {
338                out.push(prefix.to_string());
339            }
340        }
341        let mut out = Vec::new();
342        let cmd = crate::cli::Cli::command();
343        walk(&cmd, "", &mut out);
344        out
345    }
346
347    /// The gate. A clean run means: every discoverable subcommand
348    /// has a row, every verdict row speaks to every axis in the
349    /// vocabulary, and no row is stale.
350    #[test]
351    fn every_cli_surface_declares_its_coverage() {
352        let discovered = discovered_surfaces();
353        let discovered_refs: Vec<&str> = discovered.iter().map(|s| s.as_str()).collect();
354        let vocab = verdict_axes();
355        let registry = surface_registry();
356        let findings = validate_coverage(&vocab, &registry, &discovered_refs);
357        assert!(
358            findings.is_empty(),
359            "{} coverage finding(s):\n{}",
360            findings.len(),
361            findings.join("\n")
362        );
363    }
364}