dsh-ops 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,468 @@
1
+ //! Stable manifest for tool grouping, gating, and contract hashes.
2
+
3
+ use rmcp::model::Tool;
4
+ use serde::Serialize;
5
+ use sha2::{Digest, Sha256};
6
+ use std::collections::{BTreeMap, BTreeSet};
7
+
8
+ /// One independently gated tool group in the single server.
9
+ #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
10
+ #[serde(rename_all = "lowercase")]
11
+ pub enum ToolGroup {
12
+ /// Always-published file inspection and replacement tools.
13
+ File,
14
+ /// Optional bash execution and background-job tools.
15
+ Shell,
16
+ }
17
+
18
+ impl ToolGroup {
19
+ /// Returns whether this group is published for the startup flags.
20
+ pub const fn enabled(self, enable_shell: bool) -> bool {
21
+ match self {
22
+ Self::File => true,
23
+ Self::Shell => enable_shell,
24
+ }
25
+ }
26
+ }
27
+
28
+ /// Compile-time enumerable facts for one tool.
29
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
30
+ pub struct ToolManifestEntry {
31
+ /// MCP tool name without the server namespace.
32
+ pub name: &'static str,
33
+ /// Gate controlling publication.
34
+ pub group: ToolGroup,
35
+ }
36
+
37
+ /// One published tool's stable doctor contract.
38
+ #[derive(Clone, Debug, Eq, PartialEq)]
39
+ pub struct ToolContract {
40
+ /// MCP tool name without the server namespace.
41
+ pub name: String,
42
+ /// Manifest group.
43
+ pub group: ToolGroup,
44
+ /// SHA-256 over name, description, input schema, annotations, and group.
45
+ pub hash: String,
46
+ }
47
+
48
+ const TOOL_ENTRIES: [ToolManifestEntry; 9] = [
49
+ ToolManifestEntry {
50
+ name: "inspect_local_file",
51
+ group: ToolGroup::File,
52
+ },
53
+ ToolManifestEntry {
54
+ name: "grep",
55
+ group: ToolGroup::File,
56
+ },
57
+ ToolManifestEntry {
58
+ name: "glob",
59
+ group: ToolGroup::File,
60
+ },
61
+ ToolManifestEntry {
62
+ name: "replace",
63
+ group: ToolGroup::File,
64
+ },
65
+ ToolManifestEntry {
66
+ name: "run",
67
+ group: ToolGroup::Shell,
68
+ },
69
+ ToolManifestEntry {
70
+ name: "run_background",
71
+ group: ToolGroup::Shell,
72
+ },
73
+ ToolManifestEntry {
74
+ name: "job_output",
75
+ group: ToolGroup::Shell,
76
+ },
77
+ ToolManifestEntry {
78
+ name: "job_kill",
79
+ group: ToolGroup::Shell,
80
+ },
81
+ ToolManifestEntry {
82
+ name: "job_list",
83
+ group: ToolGroup::Shell,
84
+ },
85
+ ];
86
+
87
+ /// Access to the single source of enumerable tool facts.
88
+ pub struct ToolManifest;
89
+
90
+ impl ToolManifest {
91
+ /// Returns all nine manifest entries in stable presentation order.
92
+ pub const fn entries() -> &'static [ToolManifestEntry] {
93
+ &TOOL_ENTRIES
94
+ }
95
+
96
+ /// Returns expected names for one startup flag combination.
97
+ pub fn expected_names(enable_shell: bool) -> Vec<&'static str> {
98
+ TOOL_ENTRIES
99
+ .iter()
100
+ .filter(|entry| entry.group.enabled(enable_shell))
101
+ .map(|entry| entry.name)
102
+ .collect()
103
+ }
104
+
105
+ /// Validates names and explicit permission annotations against the manifest.
106
+ pub fn validate(tools: &[Tool], enable_shell: bool) -> Result<(), String> {
107
+ let mut name_counts = BTreeMap::<&str, usize>::new();
108
+ for tool in tools {
109
+ *name_counts.entry(tool.name.as_ref()).or_default() += 1;
110
+ }
111
+ let duplicates = name_counts
112
+ .into_iter()
113
+ .filter_map(|(name, count)| (count > 1).then_some(name))
114
+ .collect::<Vec<_>>();
115
+ if !duplicates.is_empty() {
116
+ return Err(format!(
117
+ "tool router contains duplicate names: {}",
118
+ duplicates.join(", ")
119
+ ));
120
+ }
121
+
122
+ let expected = Self::expected_names(enable_shell)
123
+ .into_iter()
124
+ .collect::<BTreeSet<_>>();
125
+ let actual = tools
126
+ .iter()
127
+ .map(|tool| tool.name.as_ref())
128
+ .collect::<BTreeSet<_>>();
129
+ if actual != expected {
130
+ return Err(format!(
131
+ "tool router names differ from ToolManifest: expected {expected:?}, got {actual:?}"
132
+ ));
133
+ }
134
+ for tool in tools {
135
+ let annotations = tool
136
+ .annotations
137
+ .as_ref()
138
+ .ok_or_else(|| format!("tool {} is missing annotations", tool.name))?;
139
+ if annotations.read_only_hint.is_none()
140
+ || annotations.destructive_hint.is_none()
141
+ || annotations.open_world_hint.is_none()
142
+ {
143
+ return Err(format!(
144
+ "tool {} must explicitly declare readOnlyHint, destructiveHint, and openWorldHint",
145
+ tool.name
146
+ ));
147
+ }
148
+ let expected_read_only = matches!(
149
+ tool.name.as_ref(),
150
+ "inspect_local_file" | "grep" | "glob" | "job_output" | "job_list"
151
+ );
152
+ if annotations.read_only_hint != Some(expected_read_only)
153
+ || annotations.destructive_hint != Some(false)
154
+ || annotations.open_world_hint != Some(false)
155
+ {
156
+ return Err(format!(
157
+ "tool {} has incorrect permission annotations: expected readOnlyHint={expected_read_only}, destructiveHint=false, openWorldHint=false",
158
+ tool.name
159
+ ));
160
+ }
161
+ }
162
+ Ok(())
163
+ }
164
+
165
+ /// Builds stable doctor contracts for a visible tool list.
166
+ pub fn contracts(tools: &[Tool]) -> Result<Vec<ToolContract>, String> {
167
+ let groups = TOOL_ENTRIES
168
+ .iter()
169
+ .map(|entry| (entry.name, entry.group))
170
+ .collect::<BTreeMap<_, _>>();
171
+ let mut seen = BTreeSet::new();
172
+ let mut contracts = tools
173
+ .iter()
174
+ .map(|tool| {
175
+ if !seen.insert(tool.name.as_ref()) {
176
+ return Err(format!(
177
+ "tool router contains duplicate name: {}",
178
+ tool.name
179
+ ));
180
+ }
181
+ let group = groups
182
+ .get(tool.name.as_ref())
183
+ .copied()
184
+ .ok_or_else(|| format!("tool {} has no ToolManifest entry", tool.name))?;
185
+ Ok(ToolContract {
186
+ name: tool.name.to_string(),
187
+ group,
188
+ hash: contract_hash(tool, group),
189
+ })
190
+ })
191
+ .collect::<Result<Vec<_>, String>>()?;
192
+ contracts.sort_by(|left, right| left.name.cmp(&right.name));
193
+ Ok(contracts)
194
+ }
195
+ }
196
+
197
+ fn contract_hash(tool: &Tool, group: ToolGroup) -> String {
198
+ #[derive(Serialize)]
199
+ struct ContractHashInput<'a> {
200
+ name: &'a str,
201
+ description: Option<&'a str>,
202
+ input_schema: &'a serde_json::Map<String, serde_json::Value>,
203
+ annotations: &'a Option<rmcp::model::ToolAnnotations>,
204
+ group: ToolGroup,
205
+ }
206
+
207
+ let bytes = serde_json::to_vec(&ContractHashInput {
208
+ name: tool.name.as_ref(),
209
+ description: tool.description.as_deref(),
210
+ input_schema: tool.input_schema.as_ref(),
211
+ annotations: &tool.annotations,
212
+ group,
213
+ })
214
+ .expect("tool contract fields are serializable");
215
+ hex::encode(Sha256::digest(bytes))
216
+ }
217
+
218
+ #[cfg(test)]
219
+ mod tests {
220
+ use super::{ToolGroup, ToolManifest, contract_hash};
221
+ use crate::server::{FastCtxServer, ServerOptions};
222
+ use std::collections::BTreeSet;
223
+
224
+ #[test]
225
+ fn enabled_combinations_have_the_frozen_counts_and_order() {
226
+ assert_eq!(ToolManifest::expected_names(false).len(), 4);
227
+ assert_eq!(ToolManifest::expected_names(true).len(), 9);
228
+ assert_eq!(ToolManifest::entries()[0].group, ToolGroup::File);
229
+ assert_eq!(ToolManifest::entries()[3].name, "replace");
230
+ assert_eq!(ToolManifest::entries()[8].name, "job_list");
231
+ }
232
+
233
+ #[test]
234
+ fn validation_rejects_duplicate_names_and_annotation_drift() {
235
+ let mut tools = FastCtxServer::new().tool_definitions();
236
+ let duplicate = tools
237
+ .iter()
238
+ .find(|tool| tool.name == "inspect_local_file")
239
+ .unwrap()
240
+ .clone();
241
+ tools.push(duplicate);
242
+ assert_eq!(
243
+ ToolManifest::validate(&tools, false).unwrap_err(),
244
+ "tool router contains duplicate names: inspect_local_file"
245
+ );
246
+ assert_eq!(
247
+ ToolManifest::contracts(&tools).unwrap_err(),
248
+ "tool router contains duplicate name: inspect_local_file"
249
+ );
250
+
251
+ let mut missing = FastCtxServer::new().tool_definitions();
252
+ missing
253
+ .iter_mut()
254
+ .find(|tool| tool.name == "inspect_local_file")
255
+ .unwrap()
256
+ .annotations = None;
257
+ assert_eq!(
258
+ ToolManifest::validate(&missing, false).unwrap_err(),
259
+ "tool inspect_local_file is missing annotations"
260
+ );
261
+
262
+ let mut incorrect = FastCtxServer::new().tool_definitions();
263
+ incorrect
264
+ .iter_mut()
265
+ .find(|tool| tool.name == "inspect_local_file")
266
+ .unwrap()
267
+ .annotations
268
+ .as_mut()
269
+ .unwrap()
270
+ .open_world_hint = Some(true);
271
+ assert_eq!(
272
+ ToolManifest::validate(&incorrect, false).unwrap_err(),
273
+ "tool inspect_local_file has incorrect permission annotations: expected readOnlyHint=true, destructiveHint=false, openWorldHint=false"
274
+ );
275
+ }
276
+
277
+ #[test]
278
+ fn contract_hash_covers_every_contract_field() {
279
+ let tool = FastCtxServer::new()
280
+ .tool_definitions()
281
+ .into_iter()
282
+ .find(|tool| tool.name == "inspect_local_file")
283
+ .unwrap();
284
+ let before = contract_hash(&tool, ToolGroup::File);
285
+
286
+ let mut changed_name = tool.clone();
287
+ changed_name.name = "changed".into();
288
+ assert_ne!(before, contract_hash(&changed_name, ToolGroup::File));
289
+
290
+ let mut changed_description = tool.clone();
291
+ changed_description.description = Some("changed description".into());
292
+ assert_ne!(before, contract_hash(&changed_description, ToolGroup::File));
293
+
294
+ let mut changed_schema = tool.clone();
295
+ std::sync::Arc::make_mut(&mut changed_schema.input_schema)
296
+ .insert("changed".into(), serde_json::Value::Bool(true));
297
+ assert_ne!(before, contract_hash(&changed_schema, ToolGroup::File));
298
+
299
+ let mut changed_annotations = tool.clone();
300
+ changed_annotations
301
+ .annotations
302
+ .as_mut()
303
+ .unwrap()
304
+ .open_world_hint = Some(true);
305
+ assert_ne!(before, contract_hash(&changed_annotations, ToolGroup::File));
306
+
307
+ assert_ne!(before, contract_hash(&tool, ToolGroup::Shell));
308
+ }
309
+
310
+ /// Underscore-shaped identifiers model-visible prose may cite even though no tool,
311
+ /// parameter, or schema enum vouches for them.
312
+ const DOCUMENTED_PROSE_LITERALS: [&str; 2] = [
313
+ // The job_list page-size setting cited by the limit parameter's description.
314
+ "job_list_limit",
315
+ // A WHATWG encoding label used as an example value in encoding parameters.
316
+ "shift_jis",
317
+ ];
318
+
319
+ fn all_published_tools() -> Vec<rmcp::model::Tool> {
320
+ FastCtxServer::with_options(ServerOptions::all()).tool_definitions()
321
+ }
322
+
323
+ /// Labelled model-visible prose plus the identifier vocabulary the same surface
324
+ /// publishes: tool names, every schema property name, and every schema enum value.
325
+ fn model_visible_prose_and_vocabulary() -> (Vec<(String, String)>, BTreeSet<String>) {
326
+ let mut vocabulary: BTreeSet<String> = ToolManifest::entries()
327
+ .iter()
328
+ .map(|entry| entry.name.to_string())
329
+ .collect();
330
+ vocabulary.extend(
331
+ DOCUMENTED_PROSE_LITERALS
332
+ .iter()
333
+ .map(|literal| literal.to_string()),
334
+ );
335
+ let mut prose = vec![(
336
+ "server instructions".to_string(),
337
+ crate::model_guidance::server_instructions(true),
338
+ )];
339
+ for tool in all_published_tools() {
340
+ if let Some(description) = tool.description.as_deref() {
341
+ prose.push((
342
+ format!("{} description", tool.name),
343
+ description.to_string(),
344
+ ));
345
+ }
346
+ if let Some(title) = tool
347
+ .annotations
348
+ .as_ref()
349
+ .and_then(|entry| entry.title.clone())
350
+ {
351
+ prose.push((format!("{} title", tool.name), title));
352
+ }
353
+ let schema = serde_json::Value::Object((*tool.input_schema).clone());
354
+ collect_schema_prose_and_vocabulary(&schema, &tool.name, &mut prose, &mut vocabulary);
355
+ }
356
+ (prose, vocabulary)
357
+ }
358
+
359
+ fn collect_schema_prose_and_vocabulary(
360
+ value: &serde_json::Value,
361
+ tool: &str,
362
+ prose: &mut Vec<(String, String)>,
363
+ vocabulary: &mut BTreeSet<String>,
364
+ ) {
365
+ match value {
366
+ serde_json::Value::Object(map) => {
367
+ for (key, entry) in map {
368
+ match (key.as_str(), entry) {
369
+ ("properties", serde_json::Value::Object(properties)) => {
370
+ vocabulary.extend(properties.keys().cloned());
371
+ }
372
+ ("enum", serde_json::Value::Array(values)) => {
373
+ for value in values {
374
+ if let serde_json::Value::String(text) = value {
375
+ vocabulary.insert(text.clone());
376
+ }
377
+ }
378
+ }
379
+ ("description", serde_json::Value::String(text)) => {
380
+ prose.push((format!("{tool} schema"), text.clone()));
381
+ }
382
+ _ => {}
383
+ }
384
+ collect_schema_prose_and_vocabulary(entry, tool, prose, vocabulary);
385
+ }
386
+ }
387
+ serde_json::Value::Array(entries) => {
388
+ for entry in entries {
389
+ collect_schema_prose_and_vocabulary(entry, tool, prose, vocabulary);
390
+ }
391
+ }
392
+ _ => {}
393
+ }
394
+ }
395
+
396
+ /// Tokens of lowercase letters, digits, and underscores; only those containing an
397
+ /// underscore are identifier-shaped. Hyphenated words split, so "single-file" never
398
+ /// counts, while possessives shed their tail ("inspect_local_file's" resolves).
399
+ fn identifier_tokens(text: &str) -> Vec<String> {
400
+ split_tokens(text, |character: char| {
401
+ character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
402
+ })
403
+ .into_iter()
404
+ .map(|token| token.trim_matches('_').to_string())
405
+ .filter(|token| token.contains('_'))
406
+ .collect()
407
+ }
408
+
409
+ /// Word tokens where '-' and '\'' bind, so "re-read" and "read's" survive as single
410
+ /// tokens distinct from the bare word "read".
411
+ fn word_tokens(text: &str) -> Vec<String> {
412
+ split_tokens(text, |character: char| {
413
+ character.is_ascii_lowercase()
414
+ || character.is_ascii_digit()
415
+ || matches!(character, '_' | '-' | '\'')
416
+ })
417
+ }
418
+
419
+ fn split_tokens(text: &str, keep: impl Fn(char) -> bool) -> Vec<String> {
420
+ let mut tokens = Vec::new();
421
+ let mut current = String::new();
422
+ for character in text.chars() {
423
+ if keep(character) {
424
+ current.push(character);
425
+ } else if !current.is_empty() {
426
+ tokens.push(std::mem::take(&mut current));
427
+ }
428
+ }
429
+ if !current.is_empty() {
430
+ tokens.push(current);
431
+ }
432
+ tokens
433
+ }
434
+
435
+ /// The file tool shipped as `read` through 0.2.4, and renaming it stranded prose in
436
+ /// other tools' descriptions that still cited the old name. Every underscore-shaped
437
+ /// identifier in model-visible prose must resolve against the published vocabulary so
438
+ /// a rename can never strand references again (2026-08-08).
439
+ #[test]
440
+ fn model_visible_prose_cites_only_published_identifiers() {
441
+ let (prose, vocabulary) = model_visible_prose_and_vocabulary();
442
+ for (context, text) in &prose {
443
+ for token in identifier_tokens(&text.to_lowercase()) {
444
+ assert!(
445
+ vocabulary.contains(&token),
446
+ "{context} cites `{token}`, which no published tool, parameter, or documented value resolves; full text: {text}"
447
+ );
448
+ }
449
+ }
450
+ }
451
+
452
+ /// `read` was the file tool's name for three releases, so any standalone "read" in
453
+ /// model-visible prose still presents it as callable. Prose must pick another verb or
454
+ /// name inspect_local_file outright; hyphenated forms like "re-read" stay legal
455
+ /// (2026-08-08).
456
+ #[test]
457
+ fn model_visible_prose_never_says_bare_read() {
458
+ let (prose, _) = model_visible_prose_and_vocabulary();
459
+ for (context, text) in &prose {
460
+ for token in word_tokens(&text.to_lowercase()) {
461
+ assert!(
462
+ token != "read" && token != "read's",
463
+ "{context} says bare `read`, the file tool's retired name; full text: {text}"
464
+ );
465
+ }
466
+ }
467
+ }
468
+ }