majordomus_cli/gates/model.rs
1//! The CI model, typed: the gates this repository declares, the path classes that select
2//! them, and the applicability the two derive together.
3//!
4//! `.ai/repo/ci/gates.yaml` is the canonical declaration and this is a reader of it, not a
5//! second copy. `scripts/ci-plan` is the other reader; both take the same file, apply the
6//! same rules the file's own header states, and `test/cases/131_completion_gates.sh`
7//! asserts that they select the same gates for the same paths. Nothing here carries a list
8//! of gates, of jobs or of paths: a gate added to the model is answered by every surface
9//! the next time the model is read.
10//!
11//! The one deliberate difference from `ci-plan`'s matcher is documented on
12//! [`Class::matches`].
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::discovery::glob::Glob;
21use crate::metadata::yaml;
22
23/// Where the CI model lives, repository-relative. The path `scripts/ci-plan` defaults to.
24pub const MODEL_PATH: &str = ".ai/repo/ci/gates.yaml";
25
26/// The word a class uses to say that any change in it escalates the whole plan.
27const FULL: &str = "full";
28
29// ---------------------------------------------------------------- the declaration
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(untagged)]
33/// What a class selects: the word `full`, or the gates it names.
34pub enum GateClassSelects {
35 /// `gates: full` — any change in this class escalates the whole plan.
36 Word(String),
37 /// `gates: [a, b]` — exactly these, and `gates: []` — nothing.
38 Named(Vec<String>),
39}
40
41/// ```
42/// use majordomus_cli::gates::GateDecl;
43///
44/// // one gate as `.ai/repo/ci/gates.yaml` declares it
45/// let decl: GateDecl = serde_json::from_value(serde_json::json!({
46/// "id": "shell-lint",
47/// "job": "structure",
48/// "runs": "shellcheck lib/*.sh",
49/// "summary": "every script parses",
50/// "always": true
51/// }))
52/// .unwrap();
53/// assert_eq!(decl.id, "shell-lint");
54/// // `always` is what makes every plan select it, so any change can invalidate its evidence
55/// assert!(decl.always);
56/// assert!(!decl.on_demand);
57/// ```
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59/// One gate of the model, as the file declares it.
60pub struct GateDecl {
61 /// The gate's identity, unique in the model.
62 pub id: String,
63 /// The CI job that runs it.
64 pub job: String,
65 /// The command it runs.
66 pub runs: String,
67 #[serde(default)]
68 /// One line saying what it proves.
69 pub summary: String,
70 #[serde(default)]
71 /// True when every plan selects it, whatever changed.
72 pub always: bool,
73 #[serde(default, rename = "on-demand")]
74 /// True when only a plan that asks for it selects it: its runner cannot be had on
75 /// demand, so planning it on every push is planning a verdict that never arrives.
76 pub on_demand: bool,
77 #[serde(default, rename = "at-finish")]
78 /// True when the gate measures the deployment rather than the tree, so no change to
79 /// any file can select it and the path classes can never reach it. `finish` asks it
80 /// instead: the question "is what the public is being served still a projection of
81 /// the trunk" has no answer a commit can give, and its moment is when a session
82 /// claims to be done.
83 pub at_finish: bool,
84 #[serde(default)]
85 /// Gates that are selected whenever this one is.
86 pub implies: Vec<String>,
87 #[serde(default)]
88 /// Gates this one cannot run without.
89 pub requires: Vec<String>,
90}
91
92/// ```
93/// use majordomus_cli::gates::GateClass;
94///
95/// // one path class as the CI model declares it: which paths select which gates
96/// let class: GateClass = serde_json::from_value(serde_json::json!({
97/// "id": "docs",
98/// "paths": ["docs/**"],
99/// "gates": ["reference-check"]
100/// }))
101/// .unwrap();
102/// assert_eq!(class.id, "docs");
103/// assert_eq!(class.paths, ["docs/**"]);
104/// ```
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106/// One path class: the paths it covers and the gates a change to them selects.
107pub struct GateClass {
108 /// The class's identity.
109 pub id: String,
110 #[serde(default)]
111 /// The pathspecs it covers, in the subset the model's header defines.
112 pub paths: Vec<String>,
113 #[serde(default)]
114 /// Single paths its patterns cover and the class does not: a data file under an
115 /// escalating directory, read by named gates only. Each one is a path, never a
116 /// pattern, so a file that appears under the directory tomorrow still falls in it.
117 pub except: Vec<String>,
118 #[serde(default)]
119 /// `full`, or the gates it names.
120 pub gates: Option<GateClassSelects>,
121}
122
123impl GateClass {
124 /// True when any change in this class escalates the whole plan.
125 pub(crate) fn escalates(&self) -> bool {
126 matches!(&self.gates, Some(GateClassSelects::Word(w)) if w == FULL)
127 }
128
129 /// The gates this class names; empty for `full` (which selects everything instead) and
130 /// for `gates: []`.
131 pub(crate) fn named(&self) -> &[String] {
132 match &self.gates {
133 Some(GateClassSelects::Named(g)) => g,
134 _ => &[],
135 }
136 }
137
138 /// Does one repository-relative path fall in this class?
139 ///
140 /// The pathspec subset is [`Glob`]'s, which is git's, which is what the model's header
141 /// says the patterns mean. It differs from `scripts/ci-plan`'s matcher in exactly one
142 /// place: `ci-plan` compiles `share/**` to a regular expression requiring a slash, so
143 /// the bare directory `share` does not match, and `Glob` lets `**` stand for no
144 /// segments, so it does. Nothing is decided over a directory here — the paths are
145 /// `git diff --name-only`'s, which are files — so the two agree over every input either
146 /// is given, and `test/cases/131_completion_gates.sh` asserts that on this model.
147 ///
148 /// A path the class excepts is not in it, whatever its patterns say; it still falls in
149 /// every other class that matches it, and in none it is unclassified.
150 pub(crate) fn matches(&self, path: &str) -> bool {
151 !self.except.iter().any(|e| e == path)
152 && self.paths.iter().any(|p| Glob::new(p).matches(path))
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157/// The CI model as `.ai/repo/ci/gates.yaml` declares it.
158pub struct GateModel {
159 /// The schema version; 1 is the only one.
160 pub version: u32,
161 /// Every gate, in declaration order.
162 pub gates: Vec<GateDecl>,
163 /// Every path class, in declaration order.
164 pub classes: Vec<GateClass>,
165}
166
167impl GateModel {
168 /// Read the model from a repository root, or say why not.
169 ///
170 /// A repository with no model is not an error here: it is a repository whose gates
171 /// cannot be known, which every caller reports as [`super::judge::GateStatus`]
172 /// `unknown` rather than as a pass.
173 pub(crate) fn load(root: &Path) -> Result<GateModel, String> {
174 let path = root.join(MODEL_PATH);
175 let text = std::fs::read_to_string(&path)
176 .map_err(|e| format!("{MODEL_PATH} could not be read: {e}"))?;
177 let model: GateModel =
178 yaml::parse_into(&text).map_err(|e| format!("{MODEL_PATH} does not parse: {e}"))?;
179 if model.version != 1 {
180 return Err(format!(
181 "{MODEL_PATH}: version must be 1, not {}",
182 model.version
183 ));
184 }
185 if model.gates.is_empty() {
186 return Err(format!("{MODEL_PATH} declares no gates"));
187 }
188 Ok(model)
189 }
190
191 /// One gate by id.
192 pub(crate) fn gate(&self, id: &str) -> Option<&GateDecl> {
193 self.gates.iter().find(|g| g.id == id)
194 }
195
196 /// The pathspecs a gate's evidence is taken over: the union of the paths of every class
197 /// that names it, plus every class that escalates (those select it too), and everything
198 /// for a gate the model marks `always` — a gate every plan selects is a gate any change
199 /// can invalidate, and saying otherwise would be evidence that never goes stale.
200 ///
201 /// Derived from the model and from nothing else: a class that starts naming a gate
202 /// starts invalidating its evidence in the same commit.
203 pub(crate) fn inputs_of(&self, id: &str) -> Vec<String> {
204 if self.gate(id).is_some_and(|g| g.always) {
205 return vec!["*".to_string()];
206 }
207 let mut out: BTreeSet<String> = BTreeSet::new();
208 for class in &self.classes {
209 if class.escalates() || class.named().iter().any(|g| g == id) {
210 out.extend(class.paths.iter().cloned());
211 }
212 }
213 out.into_iter().collect()
214 }
215}
216
217// ---------------------------------------------------------------- the plan
218
219/// ```
220/// use majordomus_cli::gates::GatePlanMode;
221///
222/// // a plan is either the gates the change selects, or everything the runners allow
223/// assert_eq!(GatePlanMode::Affected.as_str(), "affected");
224/// assert_eq!(GatePlanMode::Full.as_str(), "full");
225/// assert_ne!(GatePlanMode::Affected, GatePlanMode::Full);
226/// ```
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
228#[serde(rename_all = "lowercase")]
229/// Why the plan selects what it selects.
230pub enum GatePlanMode {
231 /// The gates of the classes the changed paths fall in.
232 Affected,
233 /// Every gate but the on-demand ones: asked for, or escalated to.
234 Full,
235}
236
237impl GatePlanMode {
238 /// The word this mode is reported under; `scripts/ci-plan`'s own.
239 ///
240 /// ```
241 /// use majordomus_cli::gates::GatePlanMode;
242 /// // the same words the shell planner prints, because there is one model and two readers
243 /// assert_eq!(GatePlanMode::Full.as_str(), "full");
244 /// assert_eq!(GatePlanMode::Affected.as_str(), "affected");
245 /// ```
246 pub fn as_str(self) -> &'static str {
247 match self {
248 GatePlanMode::Affected => "affected",
249 GatePlanMode::Full => "full",
250 }
251 }
252}
253
254/// ```
255/// use majordomus_cli::gates::GateClassMatch;
256///
257/// let hit = GateClassMatch { id: "docs".into(), paths: vec!["docs/CLI.md".into()] };
258/// // the class, and the files of the change that put it in the plan — never the class alone,
259/// // because a plan a reader cannot check is a plan a reader has to believe
260/// assert_eq!(hit.paths.len(), 1);
261/// ```
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
263/// One class a change fell in, and the paths that put it there.
264pub struct GateClassMatch {
265 /// The class's identity.
266 pub id: String,
267 /// The changed paths this class covers.
268 pub paths: Vec<String>,
269}
270
271/// ```
272/// use majordomus_cli::gates::{GateClassMatch, GatePlan, GatePlanMode};
273/// use std::collections::BTreeMap;
274///
275/// let plan = GatePlan {
276/// mode: GatePlanMode::Affected,
277/// reason: "3 path(s) fall in 1 class".into(),
278/// changed: vec!["docs/CLI.md".into()],
279/// classes: vec![GateClassMatch { id: "docs".into(), paths: vec!["docs/CLI.md".into()] }],
280/// unclassified: Vec::new(),
281/// selected: BTreeMap::from([("reference-check".to_string(), "class docs".to_string())]),
282/// excluded: BTreeMap::new(),
283/// };
284/// // the plan says what it selected and why, so a reader can check it rather than believe it
285/// assert_eq!(plan.selected["reference-check"], "class docs");
286/// assert!(!plan.selected.contains_key("rust-check"), "a document change cannot break the crate");
287/// ```
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
289/// Which gates a change must pass, and why each one is in or out.
290pub struct GatePlan {
291 /// Affected, or full.
292 pub mode: GatePlanMode,
293 /// Why, in words.
294 pub reason: String,
295 /// The changed paths the plan was computed over, sorted.
296 pub changed: Vec<String>,
297 /// The classes they fall in.
298 pub classes: Vec<GateClassMatch>,
299 /// Changed paths no class of the model covers; each one escalates the plan.
300 pub unclassified: Vec<String>,
301 /// Gate id to the reason it was selected, for the gates in the plan.
302 pub selected: BTreeMap<String, String>,
303 /// Gate id to the reason it was left out, for the gates that are not.
304 pub excluded: BTreeMap<String, String>,
305}
306
307impl GatePlan {
308 /// Is this gate in the plan?
309 pub(crate) fn selects(&self, id: &str) -> bool {
310 self.selected.contains_key(id)
311 }
312}
313
314/// The plan for a change set, by the rules the model's own header states: `always` gates
315/// run in every plan; a changed path selects the gates of every class it falls in (the
316/// union, never the first match); a class whose gates are `full` and a path no class covers
317/// both escalate the whole plan; `implies` adds a gate whenever its parent is selected;
318/// `requires` names a gate a gate cannot run without, and the plan adds it; and an
319/// on-demand gate is left out of every plan that did not ask for it, the full one included.
320///
321///
322/// The worked example is `this_repositorys_own_model_plans_by_its_own_rules` below: it reads
323/// this repository's `.ai/repo/ci/gates.yaml` when there is one, which a doctest cannot do
324/// now that the model is the crate's own.
325pub(crate) fn plan(model: &GateModel, changed: &[String], on_demand: bool) -> GatePlan {
326 let mut sorted: Vec<String> = changed
327 .iter()
328 .filter(|p| !p.trim().is_empty())
329 .cloned()
330 .collect();
331 crate::order::canonical(&mut sorted);
332 sorted.dedup();
333
334 let mut classes: BTreeMap<String, Vec<String>> = BTreeMap::new();
335 let mut unclassified: Vec<String> = Vec::new();
336 let mut escalated_by: Vec<String> = Vec::new();
337 let mut from_classes: BTreeMap<String, String> = BTreeMap::new();
338
339 for path in &sorted {
340 let mut hit = false;
341 // the union, never the first match: an overlap can only widen a plan
342 for class in &model.classes {
343 if !class.matches(path) {
344 continue;
345 }
346 hit = true;
347 classes
348 .entry(class.id.clone())
349 .or_default()
350 .push(path.clone());
351 if class.escalates() {
352 escalated_by.push(class.id.clone());
353 continue;
354 }
355 for gate in class.named() {
356 from_classes
357 .entry(gate.clone())
358 .or_insert_with(|| format!("the class {} covers a changed path", class.id));
359 }
360 }
361 if !hit {
362 unclassified.push(path.clone());
363 }
364 }
365 escalated_by.dedup();
366
367 let (mode, reason) = if !escalated_by.is_empty() {
368 (
369 GatePlanMode::Full,
370 format!(
371 "a changed path is in a class that escalates: {}",
372 escalated_by.join(" ")
373 ),
374 )
375 } else if !unclassified.is_empty() {
376 (
377 GatePlanMode::Full,
378 format!(
379 "a changed path is in no class of the model: {}",
380 unclassified.join(" ")
381 ),
382 )
383 } else if sorted.is_empty() {
384 (
385 GatePlanMode::Affected,
386 "no changed path; only the gates that always run".to_string(),
387 )
388 } else {
389 (
390 GatePlanMode::Affected,
391 "the gates of the classes the changed paths fall in".to_string(),
392 )
393 };
394
395 let mut selected: BTreeMap<String, String> = BTreeMap::new();
396 let mut excluded: BTreeMap<String, String> = BTreeMap::new();
397 for gate in &model.gates {
398 if gate.on_demand && !on_demand {
399 excluded.insert(
400 gate.id.clone(),
401 "on demand only: its runner is not available on demand, so a plan that \
402 selected it would wait for a verdict that never arrives"
403 .to_string(),
404 );
405 continue;
406 }
407 if mode == GatePlanMode::Full {
408 selected.insert(gate.id.clone(), reason.clone());
409 } else if gate.always {
410 selected.insert(gate.id.clone(), "always".to_string());
411 } else if let Some(why) = from_classes.get(&gate.id) {
412 selected.insert(gate.id.clone(), why.clone());
413 } else {
414 excluded.insert(
415 gate.id.clone(),
416 "no class of the model covers a changed path that selects it".to_string(),
417 );
418 }
419 }
420
421 // `implies` and `requires`, to a fixed point: a full Rust gate includes the registry
422 // checks, and the probe cannot run without the built site
423 loop {
424 let mut added = false;
425 for gate in &model.gates {
426 if !selected.contains_key(&gate.id) {
427 continue;
428 }
429 for other in gate.implies.iter().chain(gate.requires.iter()) {
430 if selected.contains_key(other) {
431 continue;
432 }
433 let Some(decl) = model.gate(other) else {
434 continue;
435 };
436 if decl.on_demand && !on_demand {
437 continue;
438 }
439 let why = if gate.implies.iter().any(|g| g == other) {
440 format!("implied by {}", gate.id)
441 } else {
442 format!("{} cannot run without it", gate.id)
443 };
444 excluded.remove(other);
445 selected.insert(other.clone(), why);
446 added = true;
447 }
448 }
449 if !added {
450 break;
451 }
452 }
453
454 GatePlan {
455 mode,
456 reason,
457 changed: sorted,
458 classes: classes
459 .into_iter()
460 .map(|(id, mut paths)| {
461 crate::order::canonical(&mut paths);
462 paths.dedup();
463 GateClassMatch { id, paths }
464 })
465 .collect(),
466 unclassified,
467 selected,
468 excluded,
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 /// The rules the header states, over this repository's own model rather than a fixture:
475 /// a documentation change plans the document gates and not the crate's, and a path the
476 /// model classifies nowhere escalates the whole plan rather than passing quietly.
477 #[test]
478 fn this_repositorys_own_model_plans_by_its_own_rules() {
479 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
480 let Ok(model) = GateModel::load(&root) else {
481 // a checkout with no CI model has nothing to assert about one
482 return;
483 };
484 let p = plan(&model, &["docs/README.md".to_string()], false);
485 assert_eq!(p.mode, GatePlanMode::Affected);
486 assert!(
487 !p.selects("rust-check"),
488 "a document does not rebuild the crate"
489 );
490
491 let p = plan(&model, &["nowhere/at/all.txt".to_string()], false);
492 assert_eq!(p.mode, GatePlanMode::Full, "an unclassified path escalates");
493 }
494
495 use super::*;
496
497 const MODEL: &str = r#"version: 1
498gates:
499 - id: lint
500 job: structure
501 always: true
502 runs: scripts/lint
503 summary: every script parses
504 - id: build
505 job: rust
506 implies: [registry]
507 runs: cargo build
508 summary: the crate builds
509 - id: registry
510 job: rust
511 runs: cargo run -- generate --check
512 summary: the projections are current
513 - id: probe
514 job: site
515 requires: [site]
516 runs: scripts/probe
517 summary: every route answers
518 - id: site
519 job: site
520 runs: scripts/site-build
521 summary: the site builds
522 - id: mac
523 job: macos
524 on-demand: true
525 runs: bash test/run.sh
526 summary: the suite on macOS
527classes:
528 - id: pipeline
529 paths: [.ai/repo/ci/**]
530 gates: full
531 - id: rust
532 paths: [apps/**]
533 gates: [build]
534 - id: web
535 paths: [site/**]
536 gates: [site, probe]
537 - id: inert
538 paths: [.gitignore]
539 gates: []
540"#;
541
542 fn model() -> GateModel {
543 yaml::parse_into(MODEL).expect("the fixture model parses")
544 }
545
546 #[test]
547 fn an_excepted_path_leaves_its_class_and_falls_in_the_others() {
548 let mut m = model();
549 let pipeline = m.classes.iter_mut().find(|c| c.id == "pipeline").unwrap();
550 pipeline.except = vec![".ai/repo/ci/durations.tsv".into()];
551 m.classes.push(GateClass {
552 id: "pipeline-data".into(),
553 paths: vec![".ai/repo/ci/durations.tsv".into()],
554 except: vec![],
555 gates: Some(GateClassSelects::Named(vec!["build".into()])),
556 });
557 let p = plan(&m, &[".ai/repo/ci/durations.tsv".into()], false);
558 assert_eq!(
559 p.mode,
560 GatePlanMode::Affected,
561 "a named data file does not escalate"
562 );
563 assert!(p.selects("build"), "its readers' class plans it");
564 assert!(!p.selects("site"), "and nothing else");
565
566 // the directory stays escalating around the one path it names
567 let p = plan(&m, &[".ai/repo/ci/gates.yaml".into()], false);
568 assert_eq!(p.mode, GatePlanMode::Full);
569 let p = plan(&m, &[".ai/repo/ci/added-tomorrow.tsv".into()], false);
570 assert_eq!(
571 p.mode,
572 GatePlanMode::Full,
573 "an unnamed file still escalates"
574 );
575 }
576
577 #[test]
578 fn an_always_gate_is_in_every_plan_and_a_class_gate_only_in_its_own() {
579 let m = model();
580 let p = plan(&m, &["apps/majordomus-cli/src/lib.rs".into()], false);
581 assert_eq!(p.mode, GatePlanMode::Affected);
582 assert!(p.selects("lint"), "always");
583 assert!(p.selects("build"), "the rust class names it");
584 assert!(p.selects("registry"), "build implies it");
585 assert!(!p.selects("site"), "no changed path is the site's");
586 }
587
588 #[test]
589 fn a_required_gate_is_planned_with_the_gate_that_needs_it() {
590 let m = model();
591 let p = plan(&m, &["site/content/index.md".into()], false);
592 assert!(p.selects("probe"));
593 assert!(p.selects("site"), "probe cannot run without it");
594 assert_eq!(p.selected["site"], "the class web covers a changed path");
595 }
596
597 #[test]
598 fn an_escalating_class_and_an_unclassified_path_both_widen_the_plan() {
599 let m = model();
600 let p = plan(&m, &[".ai/repo/ci/gates.yaml".into()], false);
601 assert_eq!(p.mode, GatePlanMode::Full);
602 assert!(p.selects("build") && p.selects("site"));
603
604 let p = plan(&m, &["something/nobody/declared".into()], false);
605 assert_eq!(p.mode, GatePlanMode::Full);
606 assert_eq!(
607 p.unclassified,
608 vec!["something/nobody/declared".to_string()]
609 );
610 }
611
612 #[test]
613 fn an_on_demand_gate_is_left_out_of_the_full_plan_and_says_why() {
614 let m = model();
615 let p = plan(&m, &[".ai/repo/ci/gates.yaml".into()], false);
616 assert!(!p.selects("mac"));
617 assert!(p.excluded["mac"].contains("on demand"));
618 // and it is in the plan that asked for it
619 assert!(plan(&m, &[".ai/repo/ci/gates.yaml".into()], true).selects("mac"));
620 }
621
622 #[test]
623 fn a_class_that_names_no_gate_plans_only_what_always_runs() {
624 let m = model();
625 let p = plan(&m, &[".gitignore".into()], false);
626 assert_eq!(p.mode, GatePlanMode::Affected, "the class covers the path");
627 assert!(p.selects("lint"));
628 assert!(!p.selects("build") && !p.selects("site"));
629 }
630
631 #[test]
632 fn an_always_gate_is_invalidated_by_anything_and_a_class_gate_by_its_class() {
633 let m = model();
634 assert_eq!(m.inputs_of("lint"), vec!["*".to_string()]);
635 // the escalating class selects every gate, so its paths invalidate every gate
636 let build = m.inputs_of("build");
637 assert!(build.contains(&"apps/**".to_string()));
638 assert!(build.contains(&".ai/repo/ci/**".to_string()));
639 assert!(!build.contains(&"site/**".to_string()));
640 }
641
642 #[test]
643 fn the_repositorys_own_model_reads_and_every_reference_resolves() {
644 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
645 let Ok(m) = GateModel::load(&root) else {
646 return; // an installed crate has no repository model; that is `unknown`, not a failure
647 };
648 for gate in &m.gates {
649 for other in gate.implies.iter().chain(gate.requires.iter()) {
650 assert!(m.gate(other).is_some(), "{} names {other}", gate.id);
651 }
652 assert!(!gate.job.is_empty() && !gate.runs.is_empty());
653 }
654 for class in &m.classes {
655 assert!(!class.paths.is_empty(), "class {} names no path", class.id);
656 for gate in class.named() {
657 assert!(m.gate(gate).is_some(), "class {} names {gate}", class.id);
658 }
659 }
660 }
661}