Skip to main content

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}