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 ///
149 /// Every report carries a verdict-coverage line with three buckets:
150 /// `examined` names the axes the defect verdict answers for (a
151 /// finding there fails `--strict`); `advisory` names the axes the
152 /// report renders, always or on `--include`, beside the verdict
153 /// without folding them in (stale entities, conformance findings,
154 /// anchor drift, check states: the figures are shown, the verdict
155 /// says nothing about them); `not_examined` names the axes this
156 /// surface never looks at, which another surface answers for.
157 Health(commands::health::Args),
158
159 /// Render the due-brief: open entities whose schema-declared due
160 /// date falls inside the window (default 90d), overdue first.
161 Due(commands::due::Args),
162
163 /// 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.
164 Gates(commands::gates::Args),
165
166 /// Export a mem: markdown in place, a portable `.mem` archive, JSON, one self-contained HTML page, or one agent-readable Markdown document (`llms-txt`).
167 Export(commands::export::Args),
168
169 /// Initialise a filesystem mem in the current (or named) folder.
170 /// Strict: errors out when the target is not empty.
171 Init(commands::init::InitArgs),
172
173 /// One-command cold start: workspace + default-schema mem + seed
174 /// entity + MCP wiring for your agent(s), in the current (or named)
175 /// folder. Tolerates dotfiles and README-grade files; derives the
176 /// mem name from the folder. For the strict, script-safe variant
177 /// use `memstead init`. Restart the agent session afterwards: a
178 /// session that is already running does not attach an MCP server
179 /// added while it runs.
180 Quickstart(commands::quickstart::Args),
181
182 /// Install a sealed `.mem` mem — either a local file, or `<scope>/<name>`
183 /// from the memstead.io registry. Registers it as a workspace-level
184 /// read-only mount; `memstead uninstall` is the symmetric removal.
185 /// Works on every workspace shape: a read-mem attaches to the workspace,
186 /// not to one of your mems.
187 #[cfg(feature = "mem-repo")]
188 Install(commands::install::Args),
189
190 /// Remove an installed read-mem's workspace-level mount. The global
191 /// cache copy survives by default; re-`install` re-registers it.
192 /// Works on every workspace shape, symmetric with `install`: a
193 /// workspace that can attach a read-mem can detach one.
194 #[cfg(feature = "mem-repo")]
195 Uninstall(commands::uninstall::Args),
196
197 /// Verify every anchor in a mem against its declared source — the
198 /// standalone drift statement, no binding required. Mutates no entity,
199 /// but records its findings store like any verify run.
200 #[command(name = "verify-anchors")]
201 VerifyAnchors(commands::verify_anchors::Args),
202
203 /// Publish a `.mem` archive to the registry. Triggers GitHub
204 /// Device Flow on first use; subsequent runs are silent.
205 Publish(commands::publish::Args),
206
207 /// Unpublish (hard-delete) `<scope>/<name>` from the registry.
208 /// Permitted to the original uploader and to admins. The same
209 /// `<scope>/<name>` becomes immediately re-publishable.
210 Unpublish(commands::unpublish::Args),
211
212 /// Domain-authority publishing: generate the signing key for a domain you
213 /// control and print the `.well-known` manifest to host. `publish --scope
214 /// <domain>:<handle>` then signs with that key — no GitHub account needed.
215 Domain {
216 #[command(subcommand)]
217 action: commands::domain::DomainAction,
218 },
219
220 /// Admin-only registry moderation: take a mem down or deny-list
221 /// bytes. Gated server-side by the `MEMSTEAD_ADMINS` allowlist; every
222 /// action is recorded in the registry's append-only audit log.
223 Admin {
224 #[command(subcommand)]
225 action: commands::admin::AdminAction,
226 },
227
228 /// Authenticate with a registry via GitHub Device Flow. Optional —
229 /// `publish` auto-triggers the same flow on first use.
230 Login(commands::login::Args),
231
232 /// Remove stored credentials for a registry.
233 Logout(commands::logout::Args),
234
235 /// Create a new entity. Provide `--title`, `--type`, and the required
236 /// section fields, or pass `--from <file.json>` with the full payload.
237 Create(commands::create::Args),
238
239 /// Modify an existing entity. `--expected-hash` is required for an update
240 /// that changes content, unless `--auto-hash` (refetch before write) or
241 /// `--force` (skip check) is given; an anchors-only update needs none,
242 /// since anchors sit outside the content hash.
243 Update(commands::update::Args),
244
245 /// Add or remove a typed relationship between two entities.
246 Relate(commands::relate::Args),
247
248 /// Delete an entity. Use `--dry-run` to preview impact first.
249 /// Delete is hashless by design (no post-state to race on); race
250 /// protection comes from `HAS_INCOMING_REFS` — and
251 /// `RESIDUAL_STUB_FOR_READONLY_REFERRERS` for read-only-referrer cases.
252 Delete(commands::delete::Args),
253
254 /// Rename an entity (changes ID, file path, and every incoming wiki-link).
255 Rename(commands::rename::Args),
256
257 /// Change an entity's type in place (id, path and incoming edges stay;
258 /// sections, metadata and every edge are validated against the target type).
259 Retype(commands::retype::Args),
260
261 /// Update many entities in one atomic call. Input is a JSON file
262 /// with a top-level `updates: [...]` array (one entry per entity,
263 /// each with its own hash mode and mutation fields). All-or-nothing:
264 /// if any entry fails (validation, hash mismatch, missing entity)
265 /// the whole batch is refused and NOTHING is committed — fix the
266 /// named entry and resubmit. On success the batch lands as one
267 /// commit. Mirrors `memstead update` per entry.
268 /// MEM-REPO WORKSPACES ONLY — refuses with
269 /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
270 /// `memstead quickstart` produces; fall back to one `memstead
271 /// update` per entity there.
272 #[cfg(feature = "mem-repo")]
273 #[command(name = "batch-update")]
274 BatchUpdate(commands::batch_update::Args),
275
276 /// Create many entities in one atomic call. Input is a JSON file
277 /// with a top-level `creates: [...]` array — each entry the same
278 /// shape as `create --from`, with its own provenance `note`.
279 /// Intra-batch references resolve as real targets (cycles included
280 /// where the schema permits), so a mutually-referencing set lands
281 /// in a single pass with no stubs. All-or-nothing: any invalid
282 /// entry refuses the whole batch and names EVERY failing entry.
283 /// One commit per touched mem.
284 /// MEM-REPO WORKSPACES ONLY — refuses with
285 /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
286 /// `memstead quickstart` produces; fall back to one `memstead
287 /// create` per entity there (losing atomicity and intra-batch
288 /// reference resolution).
289 #[cfg(feature = "mem-repo")]
290 #[command(name = "batch-create")]
291 BatchCreate(commands::batch_create::Args),
292
293 /// Apply many edge changes in one atomic call. Input is a JSON
294 /// file with a top-level `relates: [...]` array mixing additions
295 /// and removals, applied in order — each entry mirrors `relate`
296 /// (`from` / `rel_type` / `to`, optional `remove`, `description`,
297 /// per-entry `note`). All-or-nothing: any invalid entry refuses
298 /// the whole batch and names EVERY failing entry. One commit per
299 /// touched mem.
300 /// MEM-REPO WORKSPACES ONLY — refuses with
301 /// `UNSUPPORTED_WORKSPACE_SHAPE` on the filesystem-mem workspace
302 /// `memstead quickstart` produces; fall back to one `memstead
303 /// relate` per edge there.
304 #[cfg(feature = "mem-repo")]
305 #[command(name = "batch-relate")]
306 BatchRelate(commands::batch_relate::Args),
307
308 /// Apply parse-time-drift recovery across writable mems. Walks
309 /// `PARSED_RELATION_INVALID` warnings, re-renders affected
310 /// source entities to drop the stale rows, and reports per-entry
311 /// outcomes. Read-only-origin drops surface as skipped.
312 #[cfg(feature = "mem-repo")]
313 Recover(commands::recover::Args),
314
315 /// Read provenance anchors (E3a): `memstead anchors <id>` lists an
316 /// entity's anchors + composition; `memstead anchors --artifact <path>`
317 /// reverse-looks-up every entity whose anchor references that path
318 /// (the query the check-realization hook consumes).
319 Anchors(commands::anchors::Args),
320
321 /// List and resolve git merge conflicts in folder-backed mems —
322 /// the one sanctioned repair when a merge in the user's repo
323 /// writes conflict markers into entity files. `conflicts list`
324 /// shows conflicted entities; `conflicts resolve <id> --side
325 /// ours|theirs` keeps one side, validated before it lands and
326 /// committed as an attributed mutation.
327 Conflicts(commands::conflicts::Args),
328
329 /// Report a mem's changes since a cursor. The cursor is
330 /// backend-specific and is never a mutation's `write_id`: on a
331 /// git-branch mem pass a commit SHA (the `head` a prior call
332 /// returned, or the canonical empty-tree hash
333 /// `4b825dc642cb6eb9a060e54bf8d69288fbee4904` for a first sync);
334 /// on a folder mem pass an RFC3339 timestamp (the `ts` of the last
335 /// ledger entry you read, or empty for a first sync).
336 Changes(commands::changes::Args),
337
338 /// Record a check: "entity E checked, verdict ok | failed, via
339 /// method M" — an engine-recorded act carrying the session's
340 /// `--role`, never a mutation (entity markdown, hash, and mem
341 /// commits untouched). Derived check state serves via
342 /// `memstead entity <id> --provenance`.
343 Check(commands::check::Args),
344
345 /// Read and move the per-mem review mark — the engine's one
346 /// pointer per mem to the last human-approved state. `list` shows
347 /// every mem's mark and head; `set`/`clear` move it (explicit
348 /// target only); `diff` reports the unreviewed delta. Marks never
349 /// gate writes.
350 #[command(name = "review-mark")]
351 ReviewMark(commands::review_mark::Args),
352
353 /// Reload one writable mem's slice of the in-memory store from
354 /// its on-disk branch tip — or every writable mem when
355 /// `--mem` is omitted. CLI parity with the MCP `memstead_reload`
356 /// tool.
357 Reload(commands::reload::Args),
358
359 /// Fetch a mem's branch refs from a git remote into the mem-repo
360 /// (no local branch moves — inspect first, then `pull`). Requires a
361 /// git-branch-backed mem (`INVALID_INPUT` on folder mounts);
362 /// refuses `UNKNOWN_REMOTE` when the remote is not configured.
363 #[cfg(feature = "mem-repo")]
364 Fetch(commands::transport::FetchArgs),
365
366 /// Fast-forward a mem's branch to its fetched remote counterpart
367 /// and reload the in-memory store. Refuses `LOCAL_DIVERGENCE` when
368 /// the local branch is not an ancestor of the remote — reconcile
369 /// via `branch-reset`, or resolve on another clone and push.
370 #[cfg(feature = "mem-repo")]
371 Pull(commands::transport::PullArgs),
372
373 /// Push a mem's branch to a git remote. `--force` uses
374 /// force-with-lease semantics; without it, non-fast-forward pushes
375 /// refuse (`NON_FAST_FORWARD`). Refuses `UNKNOWN_REMOTE` when the
376 /// remote is not configured. `--all` pushes every mounted
377 /// git-branch mem's branch plus the workspace's schema-and-config
378 /// ref, fast-forward only: silent for refs already in sync, one line
379 /// per ref moved, a refused ref named while the others still go,
380 /// non-zero exit at the end.
381 #[cfg(feature = "mem-repo")]
382 Push(commands::transport::PushArgs),
383
384 /// Reset a mem's branch pointer to a target ref/SHA. Refuses to
385 /// discard commits reachable from any remote ref
386 /// (`PUSHED_COMMITS_PROTECTED`).
387 #[cfg(feature = "mem-repo")]
388 #[command(name = "branch-reset")]
389 BranchReset(commands::branch_reset::BranchResetArgs),
390
391 /// Mem lifecycle commands.
392 #[cfg(feature = "mem-repo")]
393 Mem {
394 #[command(subcommand)]
395 action: commands::mem::MemAction,
396 },
397
398 /// Mem-repo-git lifecycle commands.
399 #[cfg(feature = "mem-repo")]
400 #[command(name = "mem-repo")]
401 MemRepo {
402 #[command(subcommand)]
403 action: commands::mem_repo::MemRepoAction,
404 },
405
406 /// Introspect and configure workspace policy — `dump` reads the
407 /// effective config; `allow-create`/`revoke-create`/`allow-delete`/
408 /// `revoke-delete`/`grant-cross-link`/`revoke-cross-link`/`set-mutations`
409 /// write the mem-lifecycle allowlist, cross-mem link grants, and
410 /// mutation policy.
411 #[cfg(feature = "mem-repo")]
412 Workspace {
413 #[command(subcommand)]
414 action: commands::workspace::WorkspaceAction,
415 },
416
417 /// Author-time schema tooling. `memstead schema validate <path>`
418 /// checks a schema package directory against the engine's loader
419 /// without touching a workspace.
420 Schema(commands::schema::Args),
421
422 /// Pipeline tooling — one versioned v2 binding per pipeline, sources
423 /// inline. Nine verbs: `brief` renders a binding's run-brief (the
424 /// Markdown prompt an agent consumes); `init` scaffolds a fresh v2
425 /// record non-interactively; `migrate` converts every prior on-disk
426 /// generation (gen-1 root folders, the four-primitive store, the v1
427 /// three-file store) into v2 records in place; `enable
428 /// <build|sync|verify> <binding>` adds a missing operation block;
429 /// `edit` patches a binding's author-editable fields; `advance`
430 /// records disposition-gated sync-baseline advances; `exclude`
431 /// records authored exclusions for in-scope artifacts; `verify`
432 /// measures a binding's fidelity and records findings; `check-path`
433 /// answers deny verdicts for paths and patterns.
434 Projection(commands::projection::Args),
435}
436
437impl Command {
438 /// The subcommand's user-facing verb name, as typed on the command
439 /// line — the `verb` field the friction ledger records on a typed
440 /// refusal. Nested action groups report their top-level noun
441 /// (`mem`, `mem-repo`, `workspace`, `domain`, `admin`): per-verb
442 /// counts at that granularity already answer the design questions,
443 /// and nothing payload-shaped can leak through a static name.
444 pub fn verb(&self) -> &'static str {
445 match self {
446 Command::Status => "status",
447 Command::Entity(_) => "entity",
448 Command::Relations(_) => "relations",
449 Command::Search(_) => "search",
450 Command::List(_) => "list",
451 Command::Context(_) => "context",
452 Command::Overview(_) => "overview",
453 Command::Type(_) => "type",
454 Command::Health(_) => "health",
455 Command::Due(_) => "due",
456 Command::Gates(_) => "gates",
457 Command::Export(_) => "export",
458 Command::Init(_) => "init",
459 Command::Quickstart(_) => "quickstart",
460 #[cfg(feature = "mem-repo")]
461 Command::Install(_) => "install",
462 #[cfg(feature = "mem-repo")]
463 Command::Uninstall(_) => "uninstall",
464 Command::VerifyAnchors(_) => "verify-anchors",
465 Command::Publish(_) => "publish",
466 Command::Unpublish(_) => "unpublish",
467 Command::Domain { .. } => "domain",
468 Command::Admin { .. } => "admin",
469 Command::Login(_) => "login",
470 Command::Logout(_) => "logout",
471 Command::Create(_) => "create",
472 Command::Update(_) => "update",
473 Command::Relate(_) => "relate",
474 Command::Delete(_) => "delete",
475 Command::Rename(_) => "rename",
476 Command::Retype(_) => "retype",
477 #[cfg(feature = "mem-repo")]
478 Command::BatchUpdate(_) => "batch-update",
479 #[cfg(feature = "mem-repo")]
480 Command::BatchCreate(_) => "batch-create",
481 #[cfg(feature = "mem-repo")]
482 Command::BatchRelate(_) => "batch-relate",
483 #[cfg(feature = "mem-repo")]
484 Command::Recover(_) => "recover",
485 Command::Anchors(_) => "anchors",
486 Command::Conflicts(_) => "conflicts",
487 Command::Changes(_) => "changes",
488 Command::Check(_) => "check",
489 Command::ReviewMark(_) => "review-mark",
490 Command::Reload(_) => "reload",
491 #[cfg(feature = "mem-repo")]
492 Command::Fetch(_) => "fetch",
493 #[cfg(feature = "mem-repo")]
494 Command::Pull(_) => "pull",
495 #[cfg(feature = "mem-repo")]
496 Command::Push(_) => "push",
497 #[cfg(feature = "mem-repo")]
498 Command::BranchReset(_) => "branch-reset",
499 #[cfg(feature = "mem-repo")]
500 Command::Mem { .. } => "mem",
501 #[cfg(feature = "mem-repo")]
502 Command::MemRepo { .. } => "mem-repo",
503 #[cfg(feature = "mem-repo")]
504 Command::Workspace { .. } => "workspace",
505 Command::Schema(_) => "schema",
506 Command::Projection(_) => "projection",
507 }
508 }
509}
510
511#[cfg(test)]
512mod write_id_gloss_tests {
513 use clap::CommandFactory;
514
515 /// The CLI twin of `memstead-mcp`'s
516 /// `no_mutation_description_glosses_write_id_as_git_or_cursor`.
517 ///
518 /// That guard walks the five MCP tool descriptions and nothing
519 /// else, so it was blind to the clap tree — and the clap tree is
520 /// exactly where the defect survived a sweep: `changes` kept an
521 /// about-text reading "Pass `--since` = a prior `write_id` from a
522 /// mutation" while its own `--since` help said the cursor is never
523 /// a `write_id`. One help screen, the wrong instruction and its
524 /// correction, both on screen at once. A rename that only replaces
525 /// the identifier and never re-reads the sentence around it
526 /// produces precisely that, so the check belongs where the
527 /// sentences are.
528 ///
529 /// Walks every help string in the tree: each command's about and
530 /// long-about, and every argument's help and long-help.
531 #[test]
532 fn no_cli_help_text_glosses_write_id_as_git_or_cursor() {
533 // Each phrase would reintroduce one half of the defect: a git
534 // identity claim, or cursor advice.
535 // Structural, matching the MCP guard. This was a list of eight
536 // literals until 2026-08-27, which its own name already
537 // contradicted: "the `write_id` is a per-mem commit identifier"
538 // passes a list built for "per-mem git", and that is the exact
539 // evasion `ops/mod.rs` was rewritten to close. A sentence naming
540 // the token and calling it a commit must also name WHICH backend
541 // produces one; no sentence naming it may invite polling.
542 const CURSOR_INVITES: &[&str] = &[
543 "polling",
544 "poll via",
545 "since cursor",
546 "as the `since`",
547 "prior `write_id`",
548 "`write_id` from a mutation",
549 ];
550
551 fn texts(cmd: &clap::Command, path: &str, out: &mut Vec<(String, String)>) {
552 let mut push = |s: Option<&clap::builder::StyledStr>| {
553 if let Some(v) = s {
554 out.push((path.to_string(), v.to_string()));
555 }
556 };
557 push(cmd.get_about());
558 push(cmd.get_long_about());
559 for arg in cmd.get_arguments() {
560 if let Some(h) = arg.get_help() {
561 out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
562 }
563 if let Some(h) = arg.get_long_help() {
564 out.push((format!("{path} --{}", arg.get_id()), h.to_string()));
565 }
566 }
567 for sub in cmd.get_subcommands() {
568 if sub.get_name() == "help" {
569 continue;
570 }
571 let child = if path.is_empty() {
572 sub.get_name().to_string()
573 } else {
574 format!("{path} {}", sub.get_name())
575 };
576 texts(sub, &child, out);
577 }
578 }
579
580 let cmd = super::Cli::command();
581 let mut all = Vec::new();
582 texts(&cmd, "", &mut all);
583
584 let mut violations = Vec::new();
585 for (where_, text) in &all {
586 if !text.contains("write_id") {
587 continue;
588 }
589 // Judge EACH sentence naming the token on its own. Joining
590 // them first was the flaw in the first cut: a correct
591 // sentence later in the same help text excused a wrong one
592 // earlier, so "The `write_id` is a per-mem commit
593 // identifier" passed as long as some other sentence said
594 // "git-branch". Per-sentence also keeps a legitimate gitdir
595 // mention about something else out of scope without an
596 // allowlist, and allowlists are where the next drift hides.
597 for sentence in text.split(". ").filter(|s| s.contains("write_id")) {
598 let lower = sentence.to_lowercase();
599 if (lower.contains("commit") || lower.contains("sha"))
600 && !lower.contains("git-branch")
601 {
602 violations.push(format!(
603 "`memstead {where_}` help calls `write_id` a commit without naming \
604 which backend produces one — {sentence}"
605 ));
606 }
607 if lower.contains("gitdir") || lower.contains("include_config") {
608 violations.push(format!(
609 "`memstead {where_}` help points at a gitdir in a sentence about \
610 `write_id` — the lookup errors on a backend without one"
611 ));
612 }
613 for phrase in CURSOR_INVITES {
614 if lower.contains(phrase) {
615 violations.push(format!(
616 "`memstead {where_}` help invites polling with `write_id` \
617 (\"{phrase}\") — it is an identity, not a change cursor"
618 ));
619 }
620 }
621 }
622 }
623 assert!(
624 violations.is_empty(),
625 "write_id gloss violations in CLI help:\n {}",
626 violations.join("\n ")
627 );
628 // Guard the guard: if the token ever stops appearing in CLI
629 // help at all, the loop above passes vacuously.
630 assert!(
631 all.iter().any(|(_, t)| t.contains("write_id")),
632 "no CLI help text mentions `write_id` — this check has gone vacuous"
633 );
634
635 // Second half: the edge spelling. The loop above only inspects
636 // text that names `write_id`, so it was blind to help that
637 // documents a relation entry with the retired bare `type` —
638 // which `batch-relate`'s about-text did, describing a shape its
639 // own `deny_unknown_fields` parser refuses. A door documenting
640 // what it rejects is worse than one saying nothing.
641 const RETIRED_EDGE_SHAPES: &[&str] = &[
642 "`from` / `type` / `to`",
643 "`from`/`type`/`to`",
644 "{from, to, type}",
645 "{to, type}",
646 ];
647 let mut edge_violations = Vec::new();
648 for (where_, text) in &all {
649 for shape in RETIRED_EDGE_SHAPES {
650 if text.contains(shape) {
651 edge_violations.push(format!(
652 "`memstead {where_}` help documents a relation entry as {shape} — \
653 the type is `rel_type` on every surface and the parser refuses \
654 the retired spelling"
655 ));
656 }
657 }
658 }
659 // Vacuity floor for THIS half. The token half above asserts the
660 // token is mentioned somewhere; nothing asserted that any help
661 // text documents a relation entry at all, so if `--relation`
662 // stopped naming a shape this check would pass in silence.
663 assert!(
664 all.iter()
665 .any(|(_, t)| t.contains("REL_TYPE:") || t.contains("rel_type")),
666 "no CLI help documents a relation entry shape — this check has gone vacuous"
667 );
668 assert!(
669 edge_violations.is_empty(),
670 "retired edge spelling in CLI help:\n {}",
671 edge_violations.join("\n ")
672 );
673 }
674
675 /// Third surface class: what the CLI PRINTS, as opposed to what it
676 /// documents.
677 ///
678 /// The guard above walks the clap tree, which is help text only. It
679 /// could not see `mem init`'s receipt rendering the token under the
680 /// label "Seed commit" on a folder mem — three lines above a warning
681 /// saying the same value is not a commit. The rename had replaced
682 /// the identifier in the format argument and left the label beside
683 /// it, which is this plan's recurring failure in its third costume.
684 ///
685 /// Walks the crate's own sources for a format string that labels a
686 /// write-token value with git vocabulary. Deliberately allowlist-free:
687 /// every label was made backend-neutral instead, so an exemption list
688 /// would be the first place the next drift hides.
689 #[test]
690 fn no_rendered_cli_output_labels_a_write_id_as_a_commit() {
691 fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
692 let Ok(entries) = std::fs::read_dir(dir) else {
693 return;
694 };
695 for e in entries.flatten() {
696 let p = e.path();
697 if p.is_dir() {
698 walk(&p, out);
699 } else if p.extension().is_some_and(|x| x == "rs") {
700 out.push(p);
701 }
702 }
703 }
704 let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
705 let mut files = Vec::new();
706 walk(&src, &mut files);
707 assert!(
708 !files.is_empty(),
709 "found no sources — check has gone vacuous"
710 );
711
712 let mut violations = Vec::new();
713 let mut saw_a_render = false;
714 for path in &files {
715 let Ok(text) = std::fs::read_to_string(path) else {
716 continue;
717 };
718 // Skip this module's own failure messages, which necessarily
719 // quote the vocabulary they forbid.
720 let text = text
721 .split_once("mod write_id_gloss_tests")
722 .map(|(before, _)| before.to_string())
723 .unwrap_or(text);
724 let lines: Vec<&str> = text.lines().collect();
725 for (i, line) in lines.iter().enumerate() {
726 let renders_token = line.contains("write_id");
727 if renders_token && (line.contains("format!") || line.contains("push_str")) {
728 saw_a_render = true;
729 }
730 if !renders_token {
731 continue;
732 }
733 // Widen to a small window, not just this line. A label
734 // sits on the line above its value whenever the
735 // `format!` is wrapped, and a same-line-only rule is
736 // blind to exactly the costume the defect wore here.
737 let lo = i.saturating_sub(2);
738 let hi = (i + 3).min(lines.len());
739 let window = lines[lo..hi].join(" ").to_lowercase();
740 let renders = lines[lo..hi]
741 .iter()
742 .any(|l| l.contains("format!") || l.contains("push_str"));
743 if (window.contains("commit") || window.contains(" sha")) && renders {
744 violations.push(format!(
745 "{}:{}: {}",
746 path.file_name().unwrap_or_default().to_string_lossy(),
747 i + 1,
748 line.trim()
749 ));
750 }
751 }
752 }
753 assert!(
754 saw_a_render,
755 "no CLI source renders a write token — this check has gone vacuous"
756 );
757 assert!(
758 violations.is_empty(),
759 "rendered CLI output labels a write token with git vocabulary:\n {}",
760 violations.join("\n ")
761 );
762 }
763}