Skip to main content

memstead_cli/
cli.rs

1//! Clap derive for the `memstead` binary, lifted out of `main.rs` so
2//! the xtask doc generator can call `Cli::command()` against the same
3//! tree the binary exposes — no duplicated declarations, no drift.
4//!
5//! One crate, two build configs: the default (`mem-repo`) build
6//! exposes the full command set including the multi-mem / mem-repo
7//! lifecycle subcommands; `--no-default-features` drops those, leaving
8//! the engine-agnostic surface.
9
10use clap::{Parser, Subcommand};
11
12use crate::commands;
13
14/// Top-level `--help` epilog describing the exit-code posture. The
15/// taxonomy is intentionally coarse — success vs failure — because
16/// agents read JSON, not exit codes, and shell scripts can lift the
17/// granular `code` from `--json | jq .code`.
18///
19/// Code 6 breaks that success/failure symmetry on purpose: it means the
20/// measurement completed and the caller asked to be gated on what it
21/// found. A CI job needs three outcomes, not two, and it cannot get the
22/// third from a code that also means "the engine failed to boot". Keep
23/// it exclusive to explicit opt-in gate modes — the moment a run that
24/// FAILED returns 6, the distinction stops being worth anything.
25///
26/// The line is "did the measurement complete", not "was everything
27/// well". An artifact the pass could not read is a finding: it was
28/// observed and could not be adjudicated, which is an answer. An
29/// unreadable anchors sidecar is not: nothing could be observed at all,
30/// so verify refuses with `ANCHORS_SIDECAR_UNREADABLE` rather than
31/// reporting every artifact uncovered — that was a live defect, found
32/// 2026-08-21, where a corrupt file produced a red build blaming the
33/// mem.
34///
35/// This string is the source the published reference renders from
36/// (`docs-site/.../reference/cli/cli.md`, xtask-generated and
37/// drift-gated). Editing the table here and not regenerating leaves the
38/// published page asserting an exit-code space the binary no longer has.
39pub const EXIT_CODES_HELP: &str = "\
40Exit codes:
41  0  success
42  1  generic failure (catch-all for non-classified errors)
43  2  usage error (clap argument-parse failure — unknown flag, bad value)
44  3  not found (entity / mem / resource missing)
45  4  hash mismatch (optimistic-locking failure on a mutation)
46  5  validation / schema / policy refusal
47  6  findings present — the measurement COMPLETED and recorded
48     something you asked to be gated on
49     (`projection verify --fail-on-findings`). A run that could not
50     complete returns its own code above, so a CI job can tell \"the
51     mem and its source disagree\" from \"the engine could not run\".
52     An artifact the pass could not read is a finding, not an error:
53     it was observed, and not being able to adjudicate it is the
54     measurement's answer.
55
56  For programmatic branching, prefer `--json` over the exit code:
57    memstead <subcommand> ... --json | jq -r .code
58  One caveat, and it bites exactly where code 6 matters: a gate-mode run
59  that exits 6 emits TWO documents on stdout — the report, then the typed
60  error. The recipe above reads only the first and prints `null`. Read the
61  stream instead:
62    memstead ... --fail-on-findings --json | jq -s -r '.[-1].code'
63  The JSON envelope's `code` field carries the typed token
64  (e.g. INVALID_TITLE, HAS_INCOMING_REFS, CROSS_MEM_LINK_NOT_ALLOWED)
65  with structured recovery details under `.details`.";
66
67/// Query and mutate Memstead knowledge graphs from the shell.
68#[derive(Parser, Debug)]
69// `--version` prints the full build version (engine semver plus the
70// git build sha for dev builds) so two builds between releases stay
71// distinguishable in the field.
72#[command(name = "memstead", version = memstead_base::build_info::full_version(), about, long_about = None, after_long_help = EXIT_CODES_HELP)]
73pub struct Cli {
74    /// Emit JSON instead of markdown. Matches MCP `structured_content` shape.
75    #[arg(long, global = true)]
76    pub json: bool,
77
78    /// Suppress engine startup logs on stderr.
79    #[arg(long, global = true)]
80    pub quiet: bool,
81
82    /// Operate on the workspace at PATH instead of walking up from the
83    /// current directory (like `git -C`: the process runs as if
84    /// invoked from PATH, so relative path arguments resolve against
85    /// it). Also settable via the `MEMSTEAD_WORKSPACE` environment
86    /// variable; the flag wins when both are present. A PATH that is
87    /// not an initialised workspace refuses with
88    /// `WORKSPACE_NOT_INITIALISED` naming the path — it never falls
89    /// back to the directory walk.
90    #[arg(long, global = true, value_name = "PATH")]
91    pub workspace: Option<std::path::PathBuf>,
92
93    /// Declare the role this invocation's mutations are performed in
94    /// (agent-trust plan 13): `author` | `checker` | `verifier`.
95    /// Recorded immutably alongside each mutation (commit trailer /
96    /// ledger). Omit to record mutations as unspecified — legal
97    /// forever, never refused.
98    #[arg(long = "role", global = true)]
99    pub role: Option<String>,
100
101    /// Declare WHO is acting in this invocation (agent-trust plan
102    /// 15): an opaque identity string of your choosing — an agent
103    /// name, a session handle, a person's tag. Recorded immutably
104    /// alongside each mutation and check (commit trailer / ledger);
105    /// the author≠checker independence gate compares identities and
106    /// nothing else. Also settable via the `MEMSTEAD_IDENTITY`
107    /// environment variable; the flag wins when both are present.
108    /// Caller-declared and unverified, but tamper-evident in
109    /// append-only history. Omit to record operations without an
110    /// identity — legal forever, never refused; identity-less
111    /// records read `unconfirmable` at the gate.
112    #[arg(long = "identity", global = true)]
113    pub identity: Option<String>,
114
115    #[command(subcommand)]
116    pub command: Command,
117}
118
119#[derive(Subcommand, Debug)]
120pub enum Command {
121    /// Node / edge counts, schema distribution, and per-binding projection state.
122    Status,
123
124    /// Read one entity as markdown.
125    Entity(commands::entity::Args),
126
127    /// List typed edges for an entity.
128    Relations(commands::relations::Args),
129
130    /// Find entities by text or graph proximity.
131    Search(commands::search::Args),
132
133    /// Filter entities by metadata (no text match — use `search` for that).
134    List(commands::list::Args),
135
136    /// Read an entity's community cluster.
137    Context(commands::context::Args),
138
139    /// All clusters with summaries and member lists. The full build
140    /// renders the same rich content the MCP `memstead_overview` tool
141    /// emits — both surfaces share the engine composer in `memstead-engine`.
142    Overview(commands::overview::Args),
143
144    /// Describe one type, or list all types when no name given.
145    Type(commands::type_cmd::Args),
146
147    /// Health summary (orphans, stubs, stale entities, missing fields).
148    Health(commands::health::Args),
149
150    /// Render the due-brief: open entities whose schema-declared due
151    /// date falls inside the window (default 90d), overdue first.
152    Due(commands::due::Args),
153
154    /// Render the gates brief: the standing of every schema-declared gated transition — closed and open entities per gate, related-check coverage, open entities in dependency order.
155    Gates(commands::gates::Args),
156
157    /// Export a mem: markdown in place, a portable `.mem` archive, JSON, one self-contained HTML page, or one agent-readable Markdown document (`llms-txt`).
158    Export(commands::export::Args),
159
160    /// Initialise a filesystem mem in the current (or named) folder.
161    /// Strict: errors out when the target is not empty.
162    Init(commands::init::InitArgs),
163
164    /// One-command cold start: workspace + default-schema mem + seed
165    /// entity + MCP wiring for your agent(s), in the current (or named)
166    /// folder. Tolerates dotfiles and README-grade files; derives the
167    /// mem name from the folder. For the strict, script-safe variant
168    /// use `memstead init`. Restart the agent session afterwards: a
169    /// session that is already running does not attach an MCP server
170    /// added while it runs.
171    Quickstart(commands::quickstart::Args),
172
173    /// Install a sealed `.mem` mem — either a local file, or `<scope>/<name>`
174    /// from the memstead.io registry. Registers it as a workspace-level
175    /// read-only mount; `memstead uninstall` is the symmetric removal.
176    /// Works on every workspace shape: a read-mem attaches to the workspace,
177    /// not to one of your mems.
178    #[cfg(feature = "mem-repo")]
179    Install(commands::install::Args),
180
181    /// Remove an installed read-mem's workspace-level mount. The global
182    /// cache copy survives by default; re-`install` re-registers it.
183    /// MEM-REPO WORKSPACES ONLY (see `install`).
184    #[cfg(feature = "mem-repo")]
185    Uninstall(commands::uninstall::Args),
186
187    /// Verify every anchor in a mem against its declared source — the
188    /// standalone drift statement, no binding required. Mutates no entity,
189    /// but records its findings store like any verify run.
190    #[command(name = "verify-anchors")]
191    VerifyAnchors(commands::verify_anchors::Args),
192
193    /// Publish a `.mem` archive to the registry. Triggers GitHub
194    /// Device Flow on first use; subsequent runs are silent.
195    Publish(commands::publish::Args),
196
197    /// Unpublish (hard-delete) `<scope>/<name>` from the registry.
198    /// Permitted to the original uploader and to admins. The same
199    /// `<scope>/<name>` becomes immediately re-publishable.
200    Unpublish(commands::unpublish::Args),
201
202    /// Domain-authority publishing: generate the signing key for a domain you
203    /// control and print the `.well-known` manifest to host. `publish --scope
204    /// <domain>:<handle>` then signs with that key — no GitHub account needed.
205    Domain {
206        #[command(subcommand)]
207        action: commands::domain::DomainAction,
208    },
209
210    /// Admin-only registry moderation: take a mem down or deny-list
211    /// bytes. Gated server-side by the `MEMSTEAD_ADMINS` allowlist; every
212    /// action is recorded in the registry's append-only audit log.
213    Admin {
214        #[command(subcommand)]
215        action: commands::admin::AdminAction,
216    },
217
218    /// Authenticate with a registry via GitHub Device Flow. Optional —
219    /// `publish` auto-triggers the same flow on first use.
220    Login(commands::login::Args),
221
222    /// Remove stored credentials for a registry.
223    Logout(commands::logout::Args),
224
225    /// Create a new entity. Provide `--title`, `--type`, and the required
226    /// section fields, or pass `--from <file.json>` with the full payload.
227    Create(commands::create::Args),
228
229    /// Modify an existing entity. `--expected-hash` is required for an update
230    /// that changes content, unless `--auto-hash` (refetch before write) or
231    /// `--force` (skip check) is given; an anchors-only update needs none,
232    /// since anchors sit outside the content hash.
233    Update(commands::update::Args),
234
235    /// Add or remove a typed relationship between two entities.
236    Relate(commands::relate::Args),
237
238    /// Delete an entity. Use `--dry-run` to preview impact first.
239    /// Delete is hashless by design (no post-state to race on); race
240    /// protection comes from `HAS_INCOMING_REFS` — and
241    /// `RESIDUAL_STUB_FOR_READONLY_REFERRERS` for read-only-referrer cases.
242    Delete(commands::delete::Args),
243
244    /// Rename an entity (changes ID, file path, and every incoming wiki-link).
245    Rename(commands::rename::Args),
246
247    /// Update many entities in one atomic call. Input is a JSON file
248    /// with a top-level `updates: [...]` array (one entry per entity,
249    /// each with its own hash mode and mutation fields). All-or-nothing:
250    /// if any entry fails (validation, hash mismatch, missing entity)
251    /// the whole batch is refused and NOTHING is committed — fix the
252    /// named entry and resubmit. On success the batch lands as one
253    /// commit. Mirrors `memstead update` per entry.
254    /// MEM-REPO WORKSPACES ONLY — refuses with
255    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
256    /// `memstead quickstart` produces; fall back to one `memstead
257    /// update` per entity there.
258    #[cfg(feature = "mem-repo")]
259    #[command(name = "batch-update")]
260    BatchUpdate(commands::batch_update::Args),
261
262    /// Create many entities in one atomic call. Input is a JSON file
263    /// with a top-level `creates: [...]` array — each entry the same
264    /// shape as `create --from`, with its own provenance `note`.
265    /// Intra-batch references resolve as real targets (cycles included
266    /// where the schema permits), so a mutually-referencing set lands
267    /// in a single pass with no stubs. All-or-nothing: any invalid
268    /// entry refuses the whole batch and names EVERY failing entry.
269    /// One commit per touched mem.
270    /// MEM-REPO WORKSPACES ONLY — refuses with
271    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
272    /// `memstead quickstart` produces; fall back to one `memstead
273    /// create` per entity there (losing atomicity and intra-batch
274    /// reference resolution).
275    #[cfg(feature = "mem-repo")]
276    #[command(name = "batch-create")]
277    BatchCreate(commands::batch_create::Args),
278
279    /// Apply many edge changes in one atomic call. Input is a JSON
280    /// file with a top-level `relates: [...]` array mixing additions
281    /// and removals, applied in order — each entry mirrors `relate`
282    /// (`from` / `rel_type` / `to`, optional `remove`, `description`,
283    /// per-entry `note`). All-or-nothing: any invalid entry refuses
284    /// the whole batch and names EVERY failing entry. One commit per
285    /// touched mem.
286    /// MEM-REPO WORKSPACES ONLY — refuses with
287    /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
288    /// `memstead quickstart` produces; fall back to one `memstead
289    /// relate` per edge there.
290    #[cfg(feature = "mem-repo")]
291    #[command(name = "batch-relate")]
292    BatchRelate(commands::batch_relate::Args),
293
294    /// Apply parse-time-drift recovery across writable mems. Walks
295    /// `PARSED_RELATION_INVALID` warnings, re-renders affected
296    /// source entities to drop the stale rows, and reports per-entry
297    /// outcomes. Read-only-origin drops surface as skipped.
298    /// MEM-REPO WORKSPACES ONLY (see `install`).
299    #[cfg(feature = "mem-repo")]
300    Recover(commands::recover::Args),
301
302    /// Read provenance anchors (E3a): `memstead anchors <id>` lists an
303    /// entity's anchors + composition; `memstead anchors --artifact <path>`
304    /// reverse-looks-up every entity whose anchor references that path
305    /// (the query the check-realization hook consumes).
306    Anchors(commands::anchors::Args),
307
308    /// List and resolve git merge conflicts in folder-backed mems —
309    /// the one sanctioned repair when a merge in the user's repo
310    /// writes conflict markers into entity files. `conflicts list`
311    /// shows conflicted entities; `conflicts resolve <id> --side
312    /// ours|theirs` keeps one side, validated before it lands and
313    /// committed as an attributed mutation.
314    Conflicts(commands::conflicts::Args),
315
316    /// Report a mem's changes since a cursor. The cursor is
317    /// backend-specific and is never a mutation's `write_id`: on a
318    /// git-branch mem pass a commit SHA (the `head` a prior call
319    /// returned, or the canonical empty-tree hash
320    /// `4b825dc642cb6eb9a060e54bf8d69288fbee4904` for a first sync);
321    /// on a folder mem pass an RFC3339 timestamp (the `ts` of the last
322    /// ledger entry you read, or empty for a first sync).
323    Changes(commands::changes::Args),
324
325    /// Record a check: "entity E checked, verdict ok | failed, via
326    /// method M" — an engine-recorded act carrying the session's
327    /// `--role`, never a mutation (entity markdown, hash, and mem
328    /// commits untouched). Derived check state serves via
329    /// `memstead entity <id> --provenance`.
330    Check(commands::check::Args),
331
332    /// Read and move the per-mem review mark — the engine's one
333    /// pointer per mem to the last human-approved state. `list` shows
334    /// every mem's mark and head; `set`/`clear` move it (explicit
335    /// target only); `diff` reports the unreviewed delta. Marks never
336    /// gate writes.
337    #[command(name = "review-mark")]
338    ReviewMark(commands::review_mark::Args),
339
340    /// Reload one writable mem's slice of the in-memory store from
341    /// its on-disk branch tip — or every writable mem when
342    /// `--mem` is omitted. CLI parity with the MCP `memstead_reload`
343    /// tool.
344    Reload(commands::reload::Args),
345
346    /// Fetch a mem's branch refs from a git remote into the mem-repo
347    /// (no local branch moves — inspect first, then `pull`). Requires a
348    /// git-branch-backed mem (`INVALID_INPUT` on folder mounts);
349    /// refuses `UNKNOWN_REMOTE` when the remote is not configured.
350    #[cfg(feature = "mem-repo")]
351    Fetch(commands::transport::FetchArgs),
352
353    /// Fast-forward a mem's branch to its fetched remote counterpart
354    /// and reload the in-memory store. Refuses `LOCAL_DIVERGENCE` when
355    /// the local branch is not an ancestor of the remote — reconcile
356    /// via `branch-reset`, or resolve on another clone and push.
357    #[cfg(feature = "mem-repo")]
358    Pull(commands::transport::PullArgs),
359
360    /// Push a mem's branch to a git remote. `--force` uses
361    /// force-with-lease semantics; without it, non-fast-forward pushes
362    /// refuse (`NON_FAST_FORWARD`). Refuses `UNKNOWN_REMOTE` when the
363    /// remote is not configured.
364    #[cfg(feature = "mem-repo")]
365    Push(commands::transport::PushArgs),
366
367    /// Reset a mem's branch pointer to a target ref/SHA. Refuses to
368    /// discard commits reachable from any remote ref
369    /// (`PUSHED_COMMITS_PROTECTED`).
370    #[cfg(feature = "mem-repo")]
371    #[command(name = "branch-reset")]
372    BranchReset(commands::branch_reset::BranchResetArgs),
373
374    /// Mem lifecycle commands.
375    #[cfg(feature = "mem-repo")]
376    Mem {
377        #[command(subcommand)]
378        action: commands::mem::MemAction,
379    },
380
381    /// Mem-repo-git lifecycle commands.
382    #[cfg(feature = "mem-repo")]
383    #[command(name = "mem-repo")]
384    MemRepo {
385        #[command(subcommand)]
386        action: commands::mem_repo::MemRepoAction,
387    },
388
389    /// Introspect and configure workspace policy — `dump` reads the
390    /// effective config; `allow-create`/`revoke-create`/`allow-delete`/
391    /// `revoke-delete`/`grant-cross-link`/`revoke-cross-link`/`set-mutations`
392    /// write the mem-lifecycle allowlist, cross-mem link grants, and
393    /// mutation policy.
394    #[cfg(feature = "mem-repo")]
395    Workspace {
396        #[command(subcommand)]
397        action: commands::workspace::WorkspaceAction,
398    },
399
400    /// Author-time schema tooling. `memstead schema validate <path>`
401    /// checks a schema package directory against the engine's loader
402    /// without touching a workspace.
403    Schema(commands::schema::Args),
404
405    /// Pipeline tooling — one versioned v2 binding per pipeline, sources
406    /// inline. `memstead projection brief <binding>` renders a binding's
407    /// run-brief (the Markdown prompt an agent consumes); `memstead
408    /// projection init` scaffolds a fresh v2 record non-interactively;
409    /// `memstead projection migrate` converts every prior on-disk generation
410    /// (gen-1 root folders, the four-primitive store, the v1 three-file
411    /// store) into v2 records in place; `memstead projection advance`
412    /// records disposition-gated sync-baseline advances; `memstead projection
413    /// enable <build|sync|verify> <binding>` adds a missing operation block.
414    Projection(commands::projection::Args),
415}
416
417impl Command {
418    /// The subcommand's user-facing verb name, as typed on the command
419    /// line — the `verb` field the friction ledger records on a typed
420    /// refusal. Nested action groups report their top-level noun
421    /// (`mem`, `mem-repo`, `workspace`, `domain`, `admin`): per-verb
422    /// counts at that granularity already answer the design questions,
423    /// and nothing payload-shaped can leak through a static name.
424    pub fn verb(&self) -> &'static str {
425        match self {
426            Command::Status => "status",
427            Command::Entity(_) => "entity",
428            Command::Relations(_) => "relations",
429            Command::Search(_) => "search",
430            Command::List(_) => "list",
431            Command::Context(_) => "context",
432            Command::Overview(_) => "overview",
433            Command::Type(_) => "type",
434            Command::Health(_) => "health",
435            Command::Due(_) => "due",
436            Command::Gates(_) => "gates",
437            Command::Export(_) => "export",
438            Command::Init(_) => "init",
439            Command::Quickstart(_) => "quickstart",
440            #[cfg(feature = "mem-repo")]
441            Command::Install(_) => "install",
442            #[cfg(feature = "mem-repo")]
443            Command::Uninstall(_) => "uninstall",
444            Command::VerifyAnchors(_) => "verify-anchors",
445            Command::Publish(_) => "publish",
446            Command::Unpublish(_) => "unpublish",
447            Command::Domain { .. } => "domain",
448            Command::Admin { .. } => "admin",
449            Command::Login(_) => "login",
450            Command::Logout(_) => "logout",
451            Command::Create(_) => "create",
452            Command::Update(_) => "update",
453            Command::Relate(_) => "relate",
454            Command::Delete(_) => "delete",
455            Command::Rename(_) => "rename",
456            #[cfg(feature = "mem-repo")]
457            Command::BatchUpdate(_) => "batch-update",
458            #[cfg(feature = "mem-repo")]
459            Command::BatchCreate(_) => "batch-create",
460            #[cfg(feature = "mem-repo")]
461            Command::BatchRelate(_) => "batch-relate",
462            #[cfg(feature = "mem-repo")]
463            Command::Recover(_) => "recover",
464            Command::Anchors(_) => "anchors",
465            Command::Conflicts(_) => "conflicts",
466            Command::Changes(_) => "changes",
467            Command::Check(_) => "check",
468            Command::ReviewMark(_) => "review-mark",
469            Command::Reload(_) => "reload",
470            #[cfg(feature = "mem-repo")]
471            Command::Fetch(_) => "fetch",
472            #[cfg(feature = "mem-repo")]
473            Command::Pull(_) => "pull",
474            #[cfg(feature = "mem-repo")]
475            Command::Push(_) => "push",
476            #[cfg(feature = "mem-repo")]
477            Command::BranchReset(_) => "branch-reset",
478            #[cfg(feature = "mem-repo")]
479            Command::Mem { .. } => "mem",
480            #[cfg(feature = "mem-repo")]
481            Command::MemRepo { .. } => "mem-repo",
482            #[cfg(feature = "mem-repo")]
483            Command::Workspace { .. } => "workspace",
484            Command::Schema(_) => "schema",
485            Command::Projection(_) => "projection",
486        }
487    }
488}
489
490#[cfg(test)]
491mod write_id_gloss_tests {
492    use clap::CommandFactory;
493
494    /// The CLI twin of `memstead-mcp`'s
495    /// `no_mutation_description_glosses_write_id_as_git_or_cursor`.
496    ///
497    /// That guard walks the five MCP tool descriptions and nothing
498    /// else, so it was blind to the clap tree — and the clap tree is
499    /// exactly where the defect survived a sweep: `changes` kept an
500    /// about-text reading "Pass `--since` = a prior `write_id` from a
501    /// mutation" while its own `--since` help said the cursor is never
502    /// a `write_id`. One help screen, the wrong instruction and its
503    /// correction, both on screen at once. A rename that only replaces
504    /// the identifier and never re-reads the sentence around it
505    /// produces precisely that, so the check belongs where the
506    /// sentences are.
507    ///
508    /// Walks every help string in the tree: each command's about and
509    /// long-about, and every argument's help and long-help.
510    #[test]
511    fn no_cli_help_text_glosses_write_id_as_git_or_cursor() {
512        // Each phrase would reintroduce one half of the defect: a git
513        // identity claim, or cursor advice.
514        // Structural, matching the MCP guard. This was a list of eight
515        // literals until 2026-08-27, which its own name already
516        // contradicted: "the `write_id` is a per-mem commit identifier"
517        // passes a list built for "per-mem git", and that is the exact
518        // evasion `ops/mod.rs` was rewritten to close. A sentence naming
519        // the token and calling it a commit must also name WHICH backend
520        // produces one; no sentence naming it may invite polling.
521        const CURSOR_INVITES: &[&str] = &[
522            "polling",
523            "poll via",
524            "since cursor",
525            "as the `since`",
526            "prior `write_id`",
527            "`write_id` from a mutation",
528        ];
529
530        fn texts(cmd: &clap::Command, path: &str, out: &mut Vec<(String, String)>) {
531            let mut push = |s: Option<&clap::builder::StyledStr>| {
532                if let Some(v) = s {
533                    out.push((path.to_string(), v.to_string()));
534                }
535            };
536            push(cmd.get_about());
537            push(cmd.get_long_about());
538            for arg in cmd.get_arguments() {
539                if let Some(h) = arg.get_help() {
540                    out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
541                }
542                if let Some(h) = arg.get_long_help() {
543                    out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
544                }
545            }
546            for sub in cmd.get_subcommands() {
547                if sub.get_name() == "help" {
548                    continue;
549                }
550                let child = if path.is_empty() {
551                    sub.get_name().to_string()
552                } else {
553                    format!("{path} {}", sub.get_name())
554                };
555                texts(sub, &child, out);
556            }
557        }
558
559        let cmd = super::Cli::command();
560        let mut all = Vec::new();
561        texts(&cmd, "", &mut all);
562
563        let mut violations = Vec::new();
564        for (where_, text) in &all {
565            if !text.contains("write_id") {
566                continue;
567            }
568            // Judge EACH sentence naming the token on its own. Joining
569            // them first was the flaw in the first cut: a correct
570            // sentence later in the same help text excused a wrong one
571            // earlier, so "The `write_id` is a per-mem commit
572            // identifier" passed as long as some other sentence said
573            // "git-branch". Per-sentence also keeps a legitimate gitdir
574            // mention about something else out of scope without an
575            // allowlist, and allowlists are where the next drift hides.
576            for sentence in text.split(". ").filter(|s| s.contains("write_id")) {
577                let lower = sentence.to_lowercase();
578                if (lower.contains("commit") || lower.contains("sha"))
579                    && !lower.contains("git-branch")
580                {
581                    violations.push(format!(
582                        "`memstead {where_}` help calls `write_id` a commit without naming \
583                         which backend produces one — {sentence}"
584                    ));
585                }
586                if lower.contains("gitdir") || lower.contains("include_config") {
587                    violations.push(format!(
588                        "`memstead {where_}` help points at a gitdir in a sentence about \
589                         `write_id` — the lookup errors on a backend without one"
590                    ));
591                }
592                for phrase in CURSOR_INVITES {
593                    if lower.contains(phrase) {
594                        violations.push(format!(
595                            "`memstead {where_}` help invites polling with `write_id` \
596                             (\"{phrase}\") — it is an identity, not a change cursor"
597                        ));
598                    }
599                }
600            }
601        }
602        assert!(
603            violations.is_empty(),
604            "write_id gloss violations in CLI help:\n  {}",
605            violations.join("\n  ")
606        );
607        // Guard the guard: if the token ever stops appearing in CLI
608        // help at all, the loop above passes vacuously.
609        assert!(
610            all.iter().any(|(_, t)| t.contains("write_id")),
611            "no CLI help text mentions `write_id` — this check has gone vacuous"
612        );
613
614        // Second half: the edge spelling. The loop above only inspects
615        // text that names `write_id`, so it was blind to help that
616        // documents a relation entry with the retired bare `type` —
617        // which `batch-relate`'s about-text did, describing a shape its
618        // own `deny_unknown_fields` parser refuses. A door documenting
619        // what it rejects is worse than one saying nothing.
620        const RETIRED_EDGE_SHAPES: &[&str] = &[
621            "`from` / `type` / `to`",
622            "`from`/`type`/`to`",
623            "{from, to, type}",
624            "{to, type}",
625        ];
626        let mut edge_violations = Vec::new();
627        for (where_, text) in &all {
628            for shape in RETIRED_EDGE_SHAPES {
629                if text.contains(shape) {
630                    edge_violations.push(format!(
631                        "`memstead {where_}` help documents a relation entry as {shape} — \
632                         the type is `rel_type` on every surface and the parser refuses \
633                         the retired spelling"
634                    ));
635                }
636            }
637        }
638        // Vacuity floor for THIS half. The token half above asserts the
639        // token is mentioned somewhere; nothing asserted that any help
640        // text documents a relation entry at all, so if `--relation`
641        // stopped naming a shape this check would pass in silence.
642        assert!(
643            all.iter()
644                .any(|(_, t)| t.contains("REL_TYPE:") || t.contains("rel_type")),
645            "no CLI help documents a relation entry shape — this check has gone vacuous"
646        );
647        assert!(
648            edge_violations.is_empty(),
649            "retired edge spelling in CLI help:\n  {}",
650            edge_violations.join("\n  ")
651        );
652    }
653
654    /// Third surface class: what the CLI PRINTS, as opposed to what it
655    /// documents.
656    ///
657    /// The guard above walks the clap tree, which is help text only. It
658    /// could not see `mem init`'s receipt rendering the token under the
659    /// label "Seed commit" on a folder mem — three lines above a warning
660    /// saying the same value is not a commit. The rename had replaced
661    /// the identifier in the format argument and left the label beside
662    /// it, which is this plan's recurring failure in its third costume.
663    ///
664    /// Walks the crate's own sources for a format string that labels a
665    /// write-token value with git vocabulary. Deliberately allowlist-free:
666    /// every label was made backend-neutral instead, so an exemption list
667    /// would be the first place the next drift hides.
668    #[test]
669    fn no_rendered_cli_output_labels_a_write_id_as_a_commit() {
670        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
671            let Ok(entries) = std::fs::read_dir(dir) else {
672                return;
673            };
674            for e in entries.flatten() {
675                let p = e.path();
676                if p.is_dir() {
677                    walk(&p, out);
678                } else if p.extension().is_some_and(|x| x == "rs") {
679                    out.push(p);
680                }
681            }
682        }
683        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
684        let mut files = Vec::new();
685        walk(&src, &mut files);
686        assert!(
687            !files.is_empty(),
688            "found no sources — check has gone vacuous"
689        );
690
691        let mut violations = Vec::new();
692        let mut saw_a_render = false;
693        for path in &files {
694            let Ok(text) = std::fs::read_to_string(path) else {
695                continue;
696            };
697            // Skip this module's own failure messages, which necessarily
698            // quote the vocabulary they forbid.
699            let text = text
700                .split_once("mod write_id_gloss_tests")
701                .map(|(before, _)| before.to_string())
702                .unwrap_or(text);
703            let lines: Vec<&str> = text.lines().collect();
704            for (i, line) in lines.iter().enumerate() {
705                let renders_token = line.contains("write_id");
706                if renders_token && (line.contains("format!") || line.contains("push_str")) {
707                    saw_a_render = true;
708                }
709                if !renders_token {
710                    continue;
711                }
712                // Widen to a small window, not just this line. A label
713                // sits on the line above its value whenever the
714                // `format!` is wrapped, and a same-line-only rule is
715                // blind to exactly the costume the defect wore here.
716                let lo = i.saturating_sub(2);
717                let hi = (i + 3).min(lines.len());
718                let window = lines[lo..hi].join(" ").to_lowercase();
719                let renders = lines[lo..hi]
720                    .iter()
721                    .any(|l| l.contains("format!") || l.contains("push_str"));
722                if (window.contains("commit") || window.contains(" sha")) && renders {
723                    violations.push(format!(
724                        "{}:{}: {}",
725                        path.file_name().unwrap_or_default().to_string_lossy(),
726                        i + 1,
727                        line.trim()
728                    ));
729                }
730            }
731        }
732        assert!(
733            saw_a_render,
734            "no CLI source renders a write token — this check has gone vacuous"
735        );
736        assert!(
737            violations.is_empty(),
738            "rendered CLI output labels a write token with git vocabulary:\n  {}",
739            violations.join("\n  ")
740        );
741    }
742}