Skip to main content

rto_render/
tool_text.rs

1//! The **one** place each shared tool description is written.
2//!
3//! `roteiro:ignore-file` — this file is prose *about* the tools, and two of them
4//! are about intent debt: `debt`'s description has to name TODO/FIXME/HACK and
5//! `todo!()`/`unimplemented!()` stubs, and `debt_density`'s has to name the prose
6//! matches ("for now", "deferred", "tbd") a reader might otherwise be surprised
7//! by. Scanned, those descriptions register as three markers the repository does
8//! not have. The same opt-out is on `markers.rs`, `check_cli.rs` and the
9//! tool-choice fixture, for the same reason: a file that documents a scanner is
10//! not a file that owes work.
11//!
12//! # Why a module of string constants
13//!
14//! Four tools are advertised on two surfaces: the served-chat registry in
15//! `roteiro`, and this crate's MCP server. The prose was written twice, and
16//! nothing compared the copies — so they drifted. Measured when this module was
17//! introduced: `sandbox_status` differed by 363 bytes, `sandbox_clear` by 197,
18//! `security_list` by 55. A served model and an MCP client were told materially
19//! different things about the same tool, including `sandbox_clear`, the one tool
20//! on either surface that destroys anything.
21//!
22//! A description is not decoration here. It carries the warnings that prevent the
23//! likeliest misuses — that `no-analyzer-on-record` is not a clean repository,
24//! that `bytes.exclusive` and not `bytes.total` is what clearing an image frees —
25//! so one surface quietly holding an older draft is a real divergence.
26//!
27//! # Why the MCP side still repeats the text
28//!
29//! It should not have to, and this is the closest the framework allows. `rmcp`'s
30//! `#[tool(description = …)]` is parsed by `darling` into a `String`, so it takes
31//! a **string literal** and rejects a path: `description = SANDBOX_STATUS` fails
32//! to compile with *"Unexpected type `path`"*. Nor can the served side simply read
33//! the MCP surface, because `serve` does not imply `mcp` — a serve-only build has
34//! no MCP module to ask.
35//!
36//! So the constant here is the **source**, `roteiro` uses it directly, and the
37//! literal `mcp.rs` is forced to carry is compared against it by
38//! `both_tool_surfaces_describe_a_tool_the_same_way`. One authority, one
39//! mechanically-checked copy — rather than two copies and a hope.
40//!
41//! Ungated on purpose: a `serve` build without `mcp` needs these too.
42
43/// `sandbox_status` — what the machine-global sandbox image store holds.
44pub const SANDBOX_STATUS: &str = "Report what the machine-global SANDBOX IMAGE STORE holds: one row per cached container \
45    image with its reference, digests, layer count, objects on disk, and size split into \
46    layers, extracted trees, the derived ext4 disk image and the guest base. MACHINE-GLOBAL, \
47    and `scope` says so: one store per asset root, shared by every repository here, so never \
48    attribute a size to the project under discussion. No `project` argument — \
49    `security_status` is the tool with two scopes. `bytes.total` is what an image references; \
50    `bytes.exclusive` is what dropping that image alone would free. They differ when images \
51    share a layer, so quote `exclusive` when saying what clearing one would give back. \
52    `objects` counts pulled content (manifest, config, one per distinct layer). Extracted \
53    trees and disk images are a cache below this one, built on first run, so a pulled-only \
54    image is complete without them; `disk_image_built`/`base_disk_built` say whether it has \
55    run. `unattributed` is bytes no image claims; `preserved` is state no pinned digest \
56    re-obtains, which `sandbox_clear` never removes. Read this before `sandbox_clear` and show \
57    the user the numbers: a destructive verb with no way to see what it will destroy is \
58    invoked blind. Every `reference` here is a value `sandbox_clear` accepts as `image`. No \
59    `limit`: one row per image, counts and sizes, never findings. Read-only.";
60
61/// `sandbox_clear` — delete cached images; the one tool that changes anything.
62pub const SANDBOX_CLEAR: &str = "DELETE cached container images from the machine-global sandbox image store, and report \
63    what that freed. The one tool here that changes anything; everything it drops is \
64    re-obtainable from a pinned digest, so it costs a re-download and never information. It \
65    cannot reach findings, memory or the graph. MACHINE-GLOBAL: one store per asset root, \
66    shared by every repository this server hosts, so clearing for one project slows the next \
67    sandboxed run for all. No `project` argument; `scope` is `machine`. Call `sandbox_status` \
68    first and show the user what is cached and what it costs — a re-pull is minutes to tens of \
69    minutes and gigabytes. `image` and `everything` are DIFFERENT REQUESTS with no default: \
70    pass `image` with a reference from `sandbox_status`, or `everything: true`. Neither is an \
71    error and does not mean everything; both is an error. `dry_run: true` removes nothing and \
72    `applied` says which happened. Report what it freed: quote `freed_bytes`, with \
73    `store_bytes_before`/`store_bytes_after` either side, rather than saying it worked. \
74    `retained` re-checks every surviving image against the disk afterwards; if any `complete` \
75    is false say so prominently — that is a damaged store, not a successful clear, and \
76    `roteiro security prefetch` is the repair. It refuses rather than guessing: a registered \
77    box, an unrecognised entry under the store root, or an index row pointing outside it each \
78    stop it with nothing removed.";
79
80/// `security_list` — stored findings, with the run evidence behind them.
81pub const SECURITY_LIST: &str = "List the SECURITY FINDINGS stored for this repository: every live findings layer with its \
82    run evidence (analyzer, version, backend, isolation, advisory database, report digest) and \
83    a page of findings. READ `coverage` FIRST. `no-analyzer-on-record` is a real outcome and \
84    NOT a clean repository — it carries NO `report` at all, so if there is no `report`, \
85    nothing was checked and you must say so rather than report zero findings. An analyzer that \
86    ran and found nothing is the other case: `coverage` is `analyzed` and `findings` is 0. \
87    Bounded, and it says when it bound something. `limit` is 1-100 (default 20) — no unlimited \
88    setting — and is findings PER LAYER; each layer carries its true `findings` count, the \
89    `page` returned, `truncated`, and how many were `omitted`. A page keeps the most severe \
90    findings first, so what is omitted is the least severe — never conclude a severity is \
91    absent from a truncated page. `cross_reference` is a view over those findings, not a \
92    replacement: it groups dependency advisories both analyzers reported, `confirmed_by` \
93    counts how many, `1` is normal rather than a discrepancy, and the `findings` total is \
94    unchanged by it. Read-only: it cannot run an analyzer or ingest a report. Ask the user to \
95    run `roteiro security run` or `roteiro security ingest` — a tool call is not a person \
96    consenting to execution.";
97
98/// `security_status` — readiness, in two separately scoped halves.
99pub const SECURITY_STATUS: &str = "Report SECURITY READINESS in TWO SEPARATELY SCOPED SECTIONS; report them separately, never \
100    merged. `machine`: this HOST — the pinned-asset cache under `asset_root`, and each \
101    analyzer's coverage matrix with `host_readiness`. Identical for every project here, and \
102    says nothing whatsoever about whether anything has been run. `host_readiness` has THREE \
103    states with different remedies: `ready` (assets provisioned AND the analyzer's program on \
104    PATH); `assets-not-provisioned` (ask the user to run `roteiro security prefetch`); \
105    `binary-not-found` (`missing_programs` names it, and ROTEIRO NEVER INSTALLS ANALYZERS — \
106    ask the user to install it or to `roteiro security ingest` a report from elsewhere). Both \
107    underlying facts (`assets_provisioned`, `missing_programs`) are ALWAYS present, so when \
108    the state is not `ready` read both: a host can lack both and `host_readiness` names only \
109    the first remedy. Do not read `ready` as more than it says: it is readiness to run ON THIS \
110    HOST. The sandboxed backend supplies analyzers from a digest-pinned image, so \
111    `binary-not-found` does not block it, and this tool does not inspect the image store, so \
112    it reports no sandbox verdict. `repository` describes ONE PROJECT — the one in its \
113    `project` field, chosen by the `project` argument — which findings layers are live, how \
114    many findings each holds, and the age of the advisory database behind each. \
115    `possibly_stale: true` whenever advisory data is involved and NEVER means current; `false` \
116    means only that there is no advisory axis. Read `repository.coverage` before concluding \
117    anything: `no-analyzer-on-record` carries no layers and means nothing has been analyzed — \
118    NOT a clean repository. COUNTS, NEVER FINDINGS; use `security_list` for those. It needs no \
119    `limit`. Read-only: it cannot provision, and `roteiro security prefetch` needs human \
120    consent, so ask the user to run it.";
121
122/// `list_tool_classes` — the index that keeps a withheld class discoverable.
123///
124/// Deliberately the shortest description here. It stands in for whole classes of
125/// prose an operator chose not to advertise, and a long stand-in would spend the
126/// saving it exists to protect: at ~90 tokens it replaces the `security` class's
127/// ~819 or the `sandbox` class's ~713.
128pub const LIST_TOOL_CLASSES: &str = "Name this server's tool CLASSES — `query`, `quality`, `security`, `sandbox` — the tools in \
129    each, and which are LOADED here. Call it before telling a user Roteiro cannot do \
130    something: a class can be left out at startup to keep its descriptions out of every \
131    turn's prompt, and `not-loaded-here` means not advertised to this session, NOT a missing \
132    capability. Report the class name so the user can restart the server with it. Takes no \
133    arguments. Read-only.";
134
135/// `check`.
136pub const CHECK: &str = "Run the AUTHORED-LAYER DRIFT CHECK — the same gate `roteiro check` exits non-zero on and \
137    the pre-commit hook reads — and return its verdict as data: ADR `[[path#Symbol]]` links \
138    that no longer resolve, `@rto:` annotations pointing at unknown or superseded ADRs, \
139    malformed ADRs, and duplicate `adr-id`s. READ `gate` FIRST. It is `pass`, `fail`, or \
140    `not-run`, and `not-run` is a real outcome: a check needs the project's repository on disk \
141    and a graph synced from the current HEAD, and when it cannot have both it refuses rather \
142    than answering about a tree that is nobody's. A `not-run` result carries NO `report` at \
143    all — so if you are looking for `violations` and there is no `report`, nothing was checked \
144    and you must say so rather than report a clean repository. `not_run_reason` says what to \
145    fix (usually: run `roteiro sync`). Read-only: it does not rebuild the graph, which is the \
146    one thing the CLI gate does that this cannot.";
147
148/// `config_secrets`.
149pub const CONFIG_SECRETS: &str = "Inventory the SECRET-NAMED config keys in the graph: their file paths, their key names, \
150    and whether each value was redacted before being stored (`state` = redacted | declared | \
151    present). Answers \"which of this repo's config surfaces deal in credentials\" and \"did \
152    anything unredacted get into this graph\". THIS IS NOT A SECRET SCANNER — state the limits \
153    when you report it, and never imply a security guarantee. It CANNOT find a hardcoded \
154    credential in source code: it reads config-key nodes, so a token in a Rust or Python \
155    string literal produces nothing here and is invisible. It CANNOT judge whether a value is \
156    valid, because it never sees one — values are redacted before they reach the store. It \
157    CANNOT tell a real secret from a placeholder: `API_TOKEN=changeme` in a committed \
158    `.env.example` and a live token are the same row. And an EMPTY RESULT DOES NOT MEAN THERE \
159    ARE NO SECRETS — it means no config key is secret-NAMED; a credential under an innocuous \
160    key like `dsn` or `endpoint` never appears. If asked to scan for secrets, say plainly that \
161    this tool cannot do it. `limit` is 1-200 (default 50) — no unlimited setting.";
162
163/// `context`.
164pub const CONTEXT: &str = "Fetch a node's CONTEXT BUNDLE: the node, its metadata, and its one-hop provenance-labelled \
165    neighbourhood, with a validity `fingerprint` that moves when the node or any neighbour \
166    changes. The grounding to answer “what is this and what is it wired to” from. Takes `key` \
167    and nothing else. BOUNDED, and it tells you when it bound something: each direction \
168    carries at most {cap} edges. When more exist, `truncated` is true, \
169    `outgoing.total`/`incoming.total` give the real counts, and `omitted` names each edge kind \
170    and how many of it are missing — so an absent `imports` edge means there are none, and a \
171    large file's missing definitions are counted rather than silently dropped. Read `omitted` \
172    before concluding anything from an absence, and use `explain` or `search` to reach what \
173    was left out.";
174
175/// `coupling`.
176pub const COUPLING: &str = "Rank symbols by DIRECTED call coupling over `calls` edges: `fan_in` (how many distinct \
177    symbols call this one), `fan_out` (how many it calls), `instability` = \
178    fan_out/(fan_in+fan_out). `order`=fan_in finds what the codebase most depends on, \
179    `order`=fan_out the symbols that reach furthest, `total` (the default) overall coupling. \
180    Call edges are resolved by simple name, so a short generically-named function can absorb \
181    every call to that name — say so if you report a high `fan_in` on one. `limit` is 1-100 \
182    (default 20) — no unlimited setting.";
183
184/// `debt`.
185pub const DEBT: &str = "List intent-debt markers found in the codebase — TODO/FIXME/HACK comments, \
186    todo!()/unimplemented!() stubs, and deferred-work notes — grouped by category (todo, \
187    fixme, hack, stub, deferred). Optional `kind` restricts to given categories. Each marker \
188    links to its enclosing symbol or file via a `contains` edge.";
189
190/// `debt_density`.
191pub const DEBT_DENSITY: &str = "Rank FILES by intent-debt DENSITY — markers per 1,000 lines — rather than by raw marker \
192    count, which ranks the biggest file first by construction. Each row carries `markers`, \
193    `lines`, `per_kloc` and a per-category split; `overall_per_kloc` is the repository \
194    baseline to read a file's figure against. Use `debt` instead when the question is which \
195    markers exist, not where they are concentrated. Two limits to pass on rather than \
196    reporting a number as a finding: the denominator is FILE LENGTH — every line, blanks and \
197    comments included — not source lines of code, so figures run lower than an SLOC tool's and \
198    flatter verbose or generated files; and the markers beneath it include prose matches (`for \
199    now`, `deferred`, `tbd`), so a design document can rank as dense debt. This is a \
200    measurement, not a gate. `limit` is 1-100 (default 20) — no unlimited setting.";
201
202/// `explain`.
203pub const EXPLAIN: &str = "Explain a graph node: its record and its provenance-labelled incoming/outgoing edges. Keys \
204    look like `sym:<lang>:<path>#<Name>`, `file:<path>`, `adr:<id>`. A key may be \
205    project-qualified (`<project>::<key>`) to follow a cross-repo link into another hosted \
206    project (see `list_projects`).";
207
208/// `list_projects`.
209pub const LIST_PROJECTS: &str = "List the projects this server hosts (often just one). Pass one as `project` to the other \
210    tools to query it (ADR-0008). A single-project server needs no `project`.";
211
212/// `path`.
213pub const PATH: &str = "Find a shortest path between two graph nodes, following edges in either direction. Each \
214    hop records the edge kind, provenance, and traversal direction (outgoing/incoming). A path \
215    lives within one project: a project-qualified `from` (<project>::<key>) selects that \
216    project (see list_projects).";
217
218/// `search`.
219pub const SEARCH: &str = "Search graph nodes by text — names, keys, paths, and captured content (doc comments, \
220    README/ADR/blueprint prose). Returns the top matches with keys and, for content-bearing \
221    nodes, a short `snippet` of the node's actual content to ground your answer; curated \
222    ADRs/blueprints and READMEs rank first, so this is the entry point for \"what is X / why\" \
223    questions. Read the `snippet`, and call `explain` on a returned key for the full content. \
224    `limit` is 1-25 (default 10) — there is no unlimited setting; narrow the query instead of \
225    asking for more.";
226
227/// The description for `name`, or `None` for a tool this module does not own.
228///
229/// The lookup exists so [`crate::mcp`] can set descriptions on its routes at
230/// build time instead of repeating the prose in a `#[tool(description = …)]`
231/// literal. That is what makes this module the **only** definition rather than an
232/// authority with a copy beside it.
233#[must_use]
234pub fn for_tool(name: &str) -> Option<String> {
235    let raw = match name {
236        "check" => CHECK,
237        "config_secrets" => CONFIG_SECRETS,
238        "context" => CONTEXT,
239        "coupling" => COUPLING,
240        "debt" => DEBT,
241        "debt_density" => DEBT_DENSITY,
242        "explain" => EXPLAIN,
243        "list_projects" => LIST_PROJECTS,
244        "list_tool_classes" => LIST_TOOL_CLASSES,
245        "path" => PATH,
246        "sandbox_clear" => SANDBOX_CLEAR,
247        "sandbox_status" => SANDBOX_STATUS,
248        "search" => SEARCH,
249        "security_list" => SECURITY_LIST,
250        "security_status" => SECURITY_STATUS,
251        _ => return None,
252    };
253    // Every description goes through the substitution, not just the one that
254    // needs it: `CONTEXT` is the only const carrying a `{cap}` placeholder
255    // today, and for the other thirteen this is a no-op. An early return for
256    // `context` beside a `context` arm in the match would leave a second path
257    // that returns the placeholder unreplaced — dead until somebody reorders
258    // the function, and then wrong in the output rather than at compile time.
259    Some(raw.replace("{cap}", &rto_graph::TOOL_CONTEXT_EDGE_CAP.to_string()))
260}
261
262#[cfg(test)]
263mod tests {
264    use super::for_tool;
265
266    /// No advertised description may still carry a `{…}` placeholder.
267    ///
268    /// `CONTEXT` holds `{cap}` so the edge cap has one source rather than a
269    /// hardcoded `50` on one surface and an interpolation on the other. The risk
270    /// that creates is a path returning the raw constant — which is exactly what a
271    /// special case beside a `match` arm for the same name would give, dead until
272    /// somebody reorders the function and then wrong in a model's prompt rather
273    /// than at compile time.
274    ///
275    /// Asserted over **every** tool, not just `context`: a placeholder added to
276    /// another constant later is the same defect, and naming only the one that has
277    /// it today would not catch it.
278    #[test]
279    fn no_description_reaches_a_caller_with_a_placeholder_in_it() {
280        for name in [
281            "check",
282            "config_secrets",
283            "context",
284            "coupling",
285            "debt",
286            "debt_density",
287            "explain",
288            "list_projects",
289            "list_tool_classes",
290            "path",
291            "sandbox_clear",
292            "sandbox_status",
293            "search",
294            "security_list",
295            "security_status",
296        ] {
297            let text = for_tool(name).expect("this module owns every name above");
298            assert!(
299                !text.contains('{'),
300                "`{name}` still carries a placeholder: {text}"
301            );
302        }
303        assert!(for_tool("list_kind").is_none(), "MCP-only, not owned here");
304        assert!(for_tool("nope").is_none());
305    }
306
307    /// The cap really is substituted, rather than the placeholder merely being
308    /// absent because somebody deleted it from the prose.
309    #[test]
310    fn context_states_the_edge_cap_the_code_enforces() {
311        let text = for_tool("context").expect("context");
312        assert!(
313            text.contains(&format!(
314                "at most {} edges",
315                rto_graph::TOOL_CONTEXT_EDGE_CAP
316            )),
317            "the cap in the prose must be the one `bound_edges` applies: {text}"
318        );
319    }
320}