1use std::collections::{BTreeMap, BTreeSet};
53use std::path::Path;
54use std::process::Command;
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59
60pub const REGISTRY: &str = "docs/generated/registry.json";
62
63pub const SURFACE_SCHEMA: &str = "majordomus/public-surface/v1";
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(tag = "origin", rename_all = "snake_case")]
77#[schemars(rename = "ReleaseSurfaceOrigin")]
78pub enum Origin {
79 WorkingTree,
81 Ref {
83 reference: String,
85 commit: String,
87 },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
98#[schemars(rename = "ReleaseHttpBinding")]
99pub struct HttpBinding {
100 pub method: String,
102 pub path: String,
104}
105
106impl std::fmt::Display for HttpBinding {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(f, "{} {}", self.method, self.path)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
133#[schemars(rename = "ReleasePublicCapability")]
134pub struct PublicCapability {
135 pub id: String,
137 pub kind: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub mcp_tool: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub mcp_resource: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub http: Option<HttpBinding>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub cli: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub input: Option<Value>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub output: Option<Value>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
175#[schemars(rename = "ReleasePublicSurface")]
176pub struct Surface {
177 pub schema: String,
179 pub origin: Origin,
181 pub capabilities: BTreeMap<String, PublicCapability>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum SurfaceError {
199 NoRegistry {
201 reference: String,
203 },
204 NoSuchRef {
206 reference: String,
208 reason: String,
210 },
211 NothingPublished,
216 Malformed {
218 source: String,
220 reason: String,
222 },
223}
224
225impl std::fmt::Display for SurfaceError {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match self {
228 SurfaceError::NoRegistry { reference } => write!(
229 f,
230 "{reference} carries no {REGISTRY}, so the surface it published cannot be read; \
231 a release before the registry was committed cannot be compared with"
232 ),
233 SurfaceError::NoSuchRef { reference, reason } => write!(
234 f,
235 "{reference} is not a commit in this repository ({reason}); \
236 a shallow clone is the usual cause — fetch the tags and their commits"
237 ),
238 SurfaceError::NothingPublished => write!(
239 f,
240 "this repository has published nothing: there is no release record and no \
241 version tag, so there is no baseline to measure this tree against"
242 ),
243 SurfaceError::Malformed { source, reason } => {
244 write!(f, "the registry at {source} could not be read: {reason}")
245 }
246 }
247 }
248}
249
250impl std::error::Error for SurfaceError {}
251
252impl Surface {
253 pub fn of_registry(registry: &crate::capability::registry::CapabilityRegistry) -> Surface {
267 let manifest = crate::generate::registry_manifest(registry);
268 Surface {
272 schema: SURFACE_SCHEMA.to_string(),
273 origin: Origin::WorkingTree,
274 capabilities: public_capabilities(&manifest),
275 }
276 }
277
278 pub fn read_ref(root: &Path, reference: &str) -> Result<Surface, SurfaceError> {
291 let commit = rev_parse(root, reference).ok_or_else(|| SurfaceError::NoSuchRef {
292 reference: reference.to_string(),
293 reason: "git could not resolve it to a commit".to_string(),
294 })?;
295 let out = Command::new("git")
296 .current_dir(root)
297 .args(["show", &format!("{reference}:{REGISTRY}")])
298 .output()
299 .map_err(|e| SurfaceError::NoSuchRef {
300 reference: reference.to_string(),
301 reason: e.to_string(),
302 })?;
303 if !out.status.success() {
304 return Err(SurfaceError::NoRegistry {
305 reference: reference.to_string(),
306 });
307 }
308 let manifest: Value =
309 serde_json::from_slice(&out.stdout).map_err(|e| SurfaceError::Malformed {
310 source: format!("{reference}:{REGISTRY}"),
311 reason: e.to_string(),
312 })?;
313 if manifest
314 .get("capabilities")
315 .and_then(|c| c.as_array())
316 .is_none()
317 {
318 return Err(SurfaceError::Malformed {
319 source: format!("{reference}:{REGISTRY}"),
320 reason: "it carries no `capabilities` array".to_string(),
321 });
322 }
323 Ok(Surface {
324 schema: SURFACE_SCHEMA.to_string(),
325 origin: Origin::Ref {
326 reference: reference.to_string(),
327 commit,
328 },
329 capabilities: public_capabilities(&manifest),
330 })
331 }
332
333 pub fn atoms(&self) -> usize {
352 self.capabilities
353 .values()
354 .map(|c| {
355 1 + usize::from(c.mcp_tool.is_some())
356 + usize::from(c.mcp_resource.is_some())
357 + usize::from(c.http.is_some())
358 + usize::from(c.cli.is_some())
359 })
360 .sum()
361 }
362
363 pub fn fingerprint(&self) -> String {
380 let mut hasher = Sha256::new();
381 hasher.update(SURFACE_SCHEMA.as_bytes());
382 hasher.update(b"\n");
383 for (id, c) in &self.capabilities {
384 hasher.update(id.as_bytes());
385 hasher.update(b"\0");
386 let bytes = serde_json::to_vec(c).unwrap_or_default();
390 hasher.update(&bytes);
391 hasher.update(b"\n");
392 }
393 format!("sha256:{}", hasher.hex())
394 }
395}
396
397fn rev_parse(root: &Path, reference: &str) -> Option<String> {
399 let out = Command::new("git")
400 .current_dir(root)
401 .args([
402 "rev-parse",
403 "--verify",
404 "--quiet",
405 &format!("{reference}^{{commit}}"),
406 ])
407 .output()
408 .ok()?;
409 if !out.status.success() {
410 return None;
411 }
412 let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
413 if id.is_empty() {
414 None
415 } else {
416 Some(id)
417 }
418}
419
420pub fn public_capabilities(manifest: &Value) -> BTreeMap<String, PublicCapability> {
438 let mut out = BTreeMap::new();
439 let Some(list) = manifest.get("capabilities").and_then(|c| c.as_array()) else {
440 return out;
441 };
442 for c in list {
443 let visibility = c
446 .get("visibility")
447 .and_then(|v| v.as_str())
448 .unwrap_or("public");
449 if visibility != "public" {
450 continue;
451 }
452 let Some(id) = c.get("id").and_then(|v| v.as_str()) else {
453 continue;
454 };
455 let exposure = c.get("exposure");
456 let mcp = exposure.and_then(|e| e.get("mcp"));
457 let http = exposure.and_then(|e| e.get("http")).and_then(|h| {
458 Some(HttpBinding {
459 method: h.get("method")?.as_str()?.to_string(),
460 path: h.get("path")?.as_str()?.to_string(),
461 })
462 });
463 let cli = exposure
464 .and_then(|e| e.get("cli"))
465 .and_then(|c| c.get("path"))
466 .and_then(|p| p.as_array())
467 .map(|parts| {
468 parts
469 .iter()
470 .filter_map(|p| p.as_str())
471 .collect::<Vec<_>>()
472 .join(" ")
473 });
474 out.insert(
475 id.to_string(),
476 PublicCapability {
477 id: id.to_string(),
478 kind: c
479 .get("kind")
480 .and_then(|v| v.as_str())
481 .unwrap_or("query")
482 .to_string(),
483 mcp_tool: mcp
484 .and_then(|m| m.get("tool"))
485 .and_then(|t| t.as_str())
486 .map(str::to_string),
487 mcp_resource: mcp
488 .and_then(|m| m.get("resource"))
489 .and_then(|r| r.get("uri"))
490 .and_then(|u| u.as_str())
491 .map(str::to_string),
492 http,
493 cli,
494 input: c.get("input").and_then(|i| i.get("schema")).map(normalise),
495 output: c.get("output").and_then(|o| o.get("schema")).map(normalise),
496 },
497 );
498 }
499 out
500}
501
502const PROSE_KEYS: &[&str] = &["description", "title", "$comment", "examples", "example"];
507
508pub fn normalise(schema: &Value) -> Value {
533 match schema {
534 Value::Object(map) => {
535 let mut out = serde_json::Map::new();
536 let keys: BTreeSet<&String> = map.keys().collect();
538 for k in keys {
539 if PROSE_KEYS.contains(&k.as_str()) {
540 continue;
541 }
542 out.insert(k.clone(), normalise(&map[k]));
543 }
544 Value::Object(out)
545 }
546 Value::Array(items) => Value::Array(items.iter().map(normalise).collect()),
547 other => other.clone(),
548 }
549}
550
551struct Sha256 {
557 state: [u32; 8],
558 buffer: Vec<u8>,
559 length: u64,
560}
561
562const K: [u32; 64] = [
563 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
564 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
565 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
566 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
567 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
568 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
569 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
570 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
571];
572
573impl Sha256 {
574 fn new() -> Sha256 {
575 Sha256 {
576 state: [
577 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
578 0x5be0cd19,
579 ],
580 buffer: Vec::with_capacity(64),
581 length: 0,
582 }
583 }
584
585 fn update(&mut self, bytes: &[u8]) {
586 self.length = self.length.wrapping_add(bytes.len() as u64);
587 self.buffer.extend_from_slice(bytes);
588 while self.buffer.len() >= 64 {
589 let block: [u8; 64] = self.buffer[..64].try_into().expect("64 bytes");
590 self.compress(&block);
591 self.buffer.drain(..64);
592 }
593 }
594
595 fn compress(&mut self, block: &[u8; 64]) {
596 let mut w = [0u32; 64];
597 for i in 0..16 {
598 w[i] = u32::from_be_bytes(block[i * 4..i * 4 + 4].try_into().expect("4 bytes"));
599 }
600 for i in 16..64 {
601 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
602 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
603 w[i] = w[i - 16]
604 .wrapping_add(s0)
605 .wrapping_add(w[i - 7])
606 .wrapping_add(s1);
607 }
608 let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = self.state;
609 for i in 0..64 {
610 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
611 let ch = (e & f) ^ ((!e) & g);
612 let t1 = h
613 .wrapping_add(s1)
614 .wrapping_add(ch)
615 .wrapping_add(K[i])
616 .wrapping_add(w[i]);
617 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
618 let maj = (a & b) ^ (a & c) ^ (b & c);
619 let t2 = s0.wrapping_add(maj);
620 h = g;
621 g = f;
622 f = e;
623 e = d.wrapping_add(t1);
624 d = c;
625 c = b;
626 b = a;
627 a = t1.wrapping_add(t2);
628 }
629 for (s, v) in self.state.iter_mut().zip([a, b, c, d, e, f, g, h]) {
630 *s = s.wrapping_add(v);
631 }
632 }
633
634 fn hex(mut self) -> String {
635 let bits = self.length.wrapping_mul(8);
636 self.buffer.push(0x80);
637 while self.buffer.len() % 64 != 56 {
638 self.buffer.push(0);
639 }
640 let tail = bits.to_be_bytes();
641 self.buffer.extend_from_slice(&tail);
642 let blocks: Vec<[u8; 64]> = self
643 .buffer
644 .chunks_exact(64)
645 .map(|c| c.try_into().expect("64 bytes"))
646 .collect();
647 self.buffer.clear();
648 for b in blocks {
649 self.compress(&b);
650 }
651 self.state.iter().map(|w| format!("{w:08x}")).collect()
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use serde_json::json;
659
660 #[test]
663 fn the_hash_is_sha256() {
664 let mut h = Sha256::new();
665 h.update(b"abc");
666 assert_eq!(
667 h.hex(),
668 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
669 );
670 let mut h = Sha256::new();
671 h.update(b"");
672 assert_eq!(
673 h.hex(),
674 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
675 );
676 let mut h = Sha256::new();
679 h.update(b"abcdbcdecdefdefgefghfghighijhi");
680 h.update(b"jkijkljklmklmnlmnomnopnopq");
681 assert_eq!(
682 h.hex(),
683 "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
684 );
685 }
686
687 #[test]
688 fn prose_is_not_contract() {
689 let a = json!({"type": "object", "description": "one wording", "properties": {"x": {"type": "string", "title": "X"}}});
690 let b = json!({"type": "object", "description": "another wording entirely", "properties": {"x": {"type": "string"}}});
691 assert_eq!(
692 normalise(&a),
693 normalise(&b),
694 "a reworded description is not a contract change"
695 );
696 }
697
698 #[test]
699 fn normalising_is_idempotent_and_orders_keys() {
700 let messy = json!({"properties": {"b": {"type": "string"}, "a": {"type": "number"}}, "type": "object"});
701 let once = normalise(&messy);
702 assert_eq!(once, normalise(&once), "normalise is a fixed point");
703 let keys: Vec<&String> = once.as_object().expect("object").keys().collect();
704 assert_eq!(keys, vec!["properties", "type"], "keys come out sorted");
705 }
706
707 #[test]
708 fn array_order_survives_normalisation() {
709 let v = json!({"prefixItems": [{"type": "string"}, {"type": "number"}]});
712 let n = normalise(&v);
713 assert_eq!(n["prefixItems"][0]["type"], "string");
714 assert_eq!(n["prefixItems"][1]["type"], "number");
715 }
716
717 fn manifest(caps: Value) -> Value {
718 json!({"schema": "majordomus/capability-registry/v1", "capabilities": caps})
719 }
720
721 #[test]
722 fn only_public_capabilities_are_surface() {
723 let m = manifest(json!([
724 {"id": "a.one", "kind": "query", "visibility": "public"},
725 {"id": "a.two", "kind": "query", "visibility": "internal"},
726 {"id": "a.three", "kind": "query"},
727 ]));
728 let caps = public_capabilities(&m);
729 assert!(caps.contains_key("a.one"));
730 assert!(
731 !caps.contains_key("a.two"),
732 "an internal capability is not a promise to anyone"
733 );
734 assert!(
735 caps.contains_key("a.three"),
736 "the registry's own default is public, and this must not be a second policy"
737 );
738 }
739
740 #[test]
741 fn every_binding_a_caller_can_hold_is_read() {
742 let m = manifest(json!([{
743 "id": "a.one", "kind": "query", "visibility": "public",
744 "exposure": {
745 "mcp": {"tool": "t", "resource": {"uri": "majordomus://r"}},
746 "http": {"method": "GET", "path": "/api/v1/x"},
747 "cli": {"path": ["a", "one"]}
748 },
749 "input": {"schema": {"type": "object"}},
750 "output": {"schema": {"type": "object"}}
751 }]));
752 let c = &public_capabilities(&m)["a.one"];
753 assert_eq!(c.mcp_tool.as_deref(), Some("t"));
754 assert_eq!(c.mcp_resource.as_deref(), Some("majordomus://r"));
755 assert_eq!(
756 c.http.as_ref().map(|h| h.to_string()).as_deref(),
757 Some("GET /api/v1/x")
758 );
759 assert_eq!(c.cli.as_deref(), Some("a one"));
760 assert!(c.input.is_some() && c.output.is_some());
761 }
762
763 fn surface_of(caps: Value) -> Surface {
764 Surface {
765 schema: SURFACE_SCHEMA.to_string(),
766 origin: Origin::WorkingTree,
767 capabilities: public_capabilities(&manifest(caps)),
768 }
769 }
770
771 #[test]
772 fn the_fingerprint_is_deterministic_and_ordering_independent() {
773 let one = surface_of(json!([
774 {"id": "b.two", "kind": "query", "visibility": "public"},
775 {"id": "a.one", "kind": "query", "visibility": "public"},
776 ]));
777 let other = surface_of(json!([
778 {"id": "a.one", "kind": "query", "visibility": "public"},
779 {"id": "b.two", "kind": "query", "visibility": "public"},
780 ]));
781 assert_eq!(
782 one.fingerprint(),
783 other.fingerprint(),
784 "the same surface enumerated in a different order is the same surface"
785 );
786 assert_eq!(one.fingerprint(), one.fingerprint(), "and it is stable");
787 assert!(one.fingerprint().starts_with("sha256:"));
788 }
789
790 #[test]
791 fn a_changed_surface_changes_the_fingerprint() {
792 let before = surface_of(json!([{"id": "a.one", "kind": "query", "visibility": "public"}]));
793 let after = surface_of(json!([
794 {"id": "a.one", "kind": "query", "visibility": "public"},
795 {"id": "a.two", "kind": "query", "visibility": "public"},
796 ]));
797 assert_ne!(before.fingerprint(), after.fingerprint());
798 }
799
800 #[test]
801 fn prose_does_not_move_the_fingerprint() {
802 let before = surface_of(json!([{
803 "id": "a.one", "kind": "query", "visibility": "public",
804 "output": {"schema": {"type": "object", "description": "before"}}
805 }]));
806 let after = surface_of(json!([{
807 "id": "a.one", "kind": "query", "visibility": "public",
808 "output": {"schema": {"type": "object", "description": "after, at length"}}
809 }]));
810 assert_eq!(before.fingerprint(), after.fingerprint());
811 }
812
813 #[test]
814 fn atoms_count_the_things_a_caller_can_hold() {
815 let s = surface_of(json!([{
816 "id": "a.one", "kind": "query", "visibility": "public",
817 "exposure": {"mcp": {"tool": "t"}, "http": {"method": "GET", "path": "/x"}}
818 }]));
819 assert_eq!(s.atoms(), 3, "the identity, the tool, the route");
820 }
821
822 #[test]
823 fn a_ref_without_a_registry_says_so_rather_than_comparing_with_nothing() {
824 let dir = tempfile::tempdir().expect("a temporary directory");
825 let run = |args: &[&str]| {
826 Command::new("git")
827 .current_dir(dir.path())
828 .args(args)
829 .output()
830 .expect("git runs");
831 };
832 run(&["init", "-q"]);
833 run(&["config", "user.email", "t@example.com"]);
834 run(&["config", "user.name", "T"]);
835 std::fs::write(dir.path().join("a.txt"), "x").expect("write");
836 run(&["add", "-A"]);
837 run(&["commit", "-qm", "one"]);
838
839 match Surface::read_ref(dir.path(), "HEAD") {
840 Err(SurfaceError::NoRegistry { reference }) => assert_eq!(reference, "HEAD"),
841 other => panic!("a ref with no committed registry must refuse: {other:?}"),
842 }
843 match Surface::read_ref(dir.path(), "v9.9.9") {
844 Err(SurfaceError::NoSuchRef { .. }) => {}
845 other => panic!("a ref that does not exist must say so: {other:?}"),
846 }
847 }
848
849 #[test]
850 fn a_ref_with_a_registry_is_read_and_names_its_commit() {
851 let dir = tempfile::tempdir().expect("a temporary directory");
852 let run = |args: &[&str]| {
853 Command::new("git")
854 .current_dir(dir.path())
855 .args(args)
856 .output()
857 .expect("git runs");
858 };
859 run(&["init", "-q"]);
860 run(&["config", "user.email", "t@example.com"]);
861 run(&["config", "user.name", "T"]);
862 std::fs::create_dir_all(dir.path().join("docs/generated")).expect("mkdir");
863 std::fs::write(
864 dir.path().join(REGISTRY),
865 serde_json::to_string(&manifest(json!([
866 {"id": "a.one", "kind": "query", "visibility": "public"}
867 ])))
868 .expect("json"),
869 )
870 .expect("write");
871 run(&["add", "-A"]);
872 run(&["commit", "-qm", "one"]);
873
874 let s = Surface::read_ref(dir.path(), "HEAD").expect("the registry is committed");
875 assert!(s.capabilities.contains_key("a.one"));
876 match s.origin {
877 Origin::Ref { reference, commit } => {
878 assert_eq!(reference, "HEAD");
879 assert_eq!(
880 commit.len(),
881 40,
882 "the verdict names a commit, not a moving name"
883 );
884 }
885 other => panic!("a ref surface carries a ref origin: {other:?}"),
886 }
887 }
888
889 #[test]
890 fn a_registry_that_is_not_one_is_refused_rather_than_read_as_empty() {
891 let dir = tempfile::tempdir().expect("a temporary directory");
892 let run = |args: &[&str]| {
893 Command::new("git")
894 .current_dir(dir.path())
895 .args(args)
896 .output()
897 .expect("git runs");
898 };
899 run(&["init", "-q"]);
900 run(&["config", "user.email", "t@example.com"]);
901 run(&["config", "user.name", "T"]);
902 std::fs::create_dir_all(dir.path().join("docs/generated")).expect("mkdir");
903 std::fs::write(dir.path().join(REGISTRY), "{\"schema\":\"x\"}").expect("write");
905 run(&["add", "-A"]);
906 run(&["commit", "-qm", "one"]);
907 match Surface::read_ref(dir.path(), "HEAD") {
908 Err(SurfaceError::Malformed { reason, .. }) => {
909 assert!(
910 reason.contains("capabilities"),
911 "it names what was missing: {reason}"
912 );
913 }
914 other => panic!("a registry with no capabilities array must refuse: {other:?}"),
915 }
916 std::fs::write(dir.path().join(REGISTRY), "not json at all").expect("write");
917 run(&["add", "-A"]);
918 run(&["commit", "-qm", "two"]);
919 assert!(matches!(
920 Surface::read_ref(dir.path(), "HEAD"),
921 Err(SurfaceError::Malformed { .. })
922 ));
923 }
924}