fraiseql_cli/schema/validator/schema_validator.rs
1//! `SchemaValidator` — validates an `IntermediateSchema` with detailed error reporting.
2
3use std::collections::HashSet;
4
5use anyhow::Result;
6use tracing::{debug, info};
7
8use super::{
9 sql_identifier::validate_sql_identifier,
10 types::{ErrorSeverity, ValidationError, ValidationReport},
11};
12use crate::schema::intermediate::IntermediateSchema;
13
14/// Strip GraphQL type modifiers (`!`, `[]`) to extract the base type name.
15///
16/// Examples: `"UUID!"` → `"UUID"`, `"[User!]!"` → `"User"`, `"String"` → `"String"`
17pub(crate) fn extract_base_type(type_str: &str) -> &str {
18 let s = type_str.trim();
19 let s = s.trim_start_matches('[').trim_end_matches(']');
20 let s = s.trim_end_matches('!').trim_start_matches('!');
21 let s = s.trim_start_matches('[').trim_end_matches(']');
22 let s = s.trim_end_matches('!');
23 s.trim()
24}
25
26/// Enhanced schema validator
27pub struct SchemaValidator;
28
29impl SchemaValidator {
30 /// Validate an intermediate schema with detailed error reporting
31 ///
32 /// # Errors
33 ///
34 /// Currently infallible; always returns `Ok` containing the report.
35 /// The `Result` return type is reserved for future validation that may
36 /// require fallible I/O.
37 #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many cross-field constraint checks
38 pub fn validate(schema: &IntermediateSchema) -> Result<ValidationReport> {
39 info!("Validating schema structure");
40
41 let mut report = ValidationReport::default();
42
43 // Build type registry.
44 //
45 // `enumerate()` rather than `type_names.len()` for the duplicate path: the count of
46 // unique names seen so far is not the offending element's index, and the two diverge
47 // exactly when there are duplicates — the one case this diagnostic exists for. With
48 // `[A, B, A, A]` the count is stuck at 2, so the old path reported `types[2]` twice
49 // and never named element 3 (#724 item 3).
50 let mut type_names = HashSet::new();
51 for (idx, type_def) in schema.types.iter().enumerate() {
52 if type_names.contains(&type_def.name) {
53 report.errors.push(ValidationError {
54 message: format!("Duplicate type name: '{}'", type_def.name),
55 path: format!("types[{idx}].name"),
56 severity: ErrorSeverity::Error,
57 suggestion: Some("Type names must be unique".to_string()),
58 });
59 }
60 type_names.insert(type_def.name.clone());
61 }
62
63 // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
64 for input_type in &schema.input_types {
65 type_names.insert(input_type.name.clone());
66 }
67
68 // Add union type names — valid as mutation/query return types
69 for union_def in &schema.unions {
70 type_names.insert(union_def.name.clone());
71 }
72
73 // Add interfaces — valid as field and return types (GraphQL spec §3.7)
74 for interface in &schema.interfaces {
75 type_names.insert(interface.name.clone());
76 }
77
78 // Add enums — valid as field and argument types
79 for enum_def in &schema.enums {
80 type_names.insert(enum_def.name.clone());
81 }
82
83 // The **composite** types, as GraphQL defines them: objects, interfaces and
84 // unions. A mutation's return type must be one of these (#1358); every other
85 // position accepts the wider `type_names`.
86 let composite_type_names: HashSet<String> = schema
87 .types
88 .iter()
89 .map(|t| t.name.clone())
90 .chain(schema.unions.iter().map(|u| u.name.clone()))
91 .chain(schema.interfaces.iter().map(|i| i.name.clone()))
92 .collect();
93
94 // Add built-in scalars
95 for scalar in crate::schema::builtin_scalar_names() {
96 type_names.insert(scalar.to_string());
97 }
98
99 // Add **declared** custom scalars.
100 //
101 // This used to register every type name appearing in any object field as an implicit
102 // custom scalar, which made field-type typos invisible: `"type": "Strng"` validated
103 // cleanly, and the typo then also legalized `Strng` as a query return type, so one
104 // mistake propagated instead of being caught (#724 item 2). Only names the author
105 // actually declared are scalars now; an undeclared field type is reported below.
106 for scalar in schema.custom_scalars.iter().flatten() {
107 type_names.insert(scalar.name.clone());
108 }
109
110 // Report field types that resolve to nothing declared.
111 //
112 // A warning rather than an error: the implicit registration it replaces was there to
113 // keep custom-scalar authoring frictionless, and there is no way to distinguish a
114 // typo from a scalar the author has declared elsewhere in a workflow this validator
115 // cannot see. Naming it is what was missing — silence was the defect.
116 for (type_idx, type_def) in schema.types.iter().enumerate() {
117 for (field_idx, field) in type_def.fields.iter().enumerate() {
118 let base = extract_base_type(&field.field_type);
119 if base.is_empty() || type_names.contains(base) {
120 continue;
121 }
122 report.errors.push(ValidationError {
123 message: format!(
124 "Field '{}.{}' has type '{base}', which is not a declared type, enum, \
125 interface, union, input type, built-in scalar or custom scalar",
126 type_def.name, field.name
127 ),
128 path: format!("types[{type_idx}].fields[{field_idx}].type"),
129 severity: ErrorSeverity::Warning,
130 suggestion: Some(format!(
131 "{} If '{base}' is a custom scalar, declare it in `custom_scalars`.",
132 Self::suggest_similar_type(base, &type_names)
133 )),
134 });
135 }
136 }
137
138 // Validate queries
139 let mut query_names = HashSet::new();
140 for (idx, query) in schema.queries.iter().enumerate() {
141 debug!("Validating query: {}", query.name);
142
143 // Check for duplicate query names
144 if query_names.contains(&query.name) {
145 report.errors.push(ValidationError {
146 message: format!("Duplicate query name: '{}'", query.name),
147 path: format!("queries[{idx}].name"),
148 severity: ErrorSeverity::Error,
149 suggestion: Some("Query names must be unique".to_string()),
150 });
151 }
152 query_names.insert(query.name.clone());
153
154 // Validate return type exists (strip ! and [] modifiers)
155 let base_return = extract_base_type(&query.return_type);
156 if !type_names.contains(base_return) {
157 report.errors.push(ValidationError {
158 message: format!(
159 "Query '{}' references unknown type '{}'",
160 query.name, base_return
161 ),
162 path: format!("queries[{idx}].return_type"),
163 severity: ErrorSeverity::Error,
164 suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
165 });
166 }
167
168 // Validate argument types
169 for (arg_idx, arg) in query.arguments.iter().enumerate() {
170 let base_arg = extract_base_type(&arg.arg_type);
171 if !type_names.contains(base_arg) {
172 report.errors.push(ValidationError {
173 message: format!(
174 "Query '{}' argument '{}' references unknown type '{}'",
175 query.name, arg.name, base_arg
176 ),
177 path: format!("queries[{idx}].arguments[{arg_idx}].type"),
178 severity: ErrorSeverity::Error,
179 suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
180 });
181 }
182 }
183
184 // Validate sql_source is a safe SQL identifier
185 if let Some(sql_source) = &query.sql_source {
186 if let Err(e) = validate_sql_identifier(
187 sql_source,
188 "sql_source",
189 &format!("Query.{}", query.name),
190 ) {
191 report.errors.push(e);
192 }
193 }
194
195 // Warning for queries without SQL source.
196 //
197 // A function-backed query (#1329) has none by construction and is not
198 // missing anything — warning there would train authors to ignore the
199 // warning that still matters for the SQL-backed case.
200 if query.sql_source.is_none() && query.function.is_none() && query.returns_list {
201 report.errors.push(ValidationError {
202 message: format!(
203 "Query '{}' returns a list but has no sql_source",
204 query.name
205 ),
206 path: format!("queries[{idx}]"),
207 severity: ErrorSeverity::Warning,
208 suggestion: Some("Add sql_source for SQL-backed queries".to_string()),
209 });
210 }
211 }
212
213 // Validate mutations
214 let mut mutation_names = HashSet::new();
215 for (idx, mutation) in schema.mutations.iter().enumerate() {
216 debug!("Validating mutation: {}", mutation.name);
217
218 // Check for duplicate mutation names
219 if mutation_names.contains(&mutation.name) {
220 report.errors.push(ValidationError {
221 message: format!("Duplicate mutation name: '{}'", mutation.name),
222 path: format!("mutations[{idx}].name"),
223 severity: ErrorSeverity::Error,
224 suggestion: Some("Mutation names must be unique".to_string()),
225 });
226 }
227 mutation_names.insert(mutation.name.clone());
228
229 // Validate return type exists (strip ! and [] modifiers)
230 let base_return = extract_base_type(&mutation.return_type);
231 if type_names.contains(base_return) {
232 // …and that it is **composite** (#1358).
233 //
234 // A mutation's result is projected out of the `app.mutation_response`
235 // envelope, which is entity-shaped by construction, so a leaf return
236 // type has no coherent meaning under it. Declaring one was accepted:
237 // `type_names` registers enums, built-in scalars and declared custom
238 // scalars alongside the composite types, because every *other*
239 // position — field types, argument types — legitimately takes them.
240 //
241 // What the runtime then did with `mutation { setStatus }` is the
242 // reason this is an error rather than a warning. A leaf return type
243 // needs no selection set, so GraphQL § 5.3.3 (#1357) correctly does
244 // not refuse the document, and the empty set reaches the projector —
245 // where an empty set means "no field filtering". The response was the
246 // whole stored entity object, under a field the schema types as an
247 // enum, with the #423 field authorizer never consulted because a leaf
248 // type has no field list to find a gated field in.
249 if !composite_type_names.contains(base_return) {
250 report.errors.push(ValidationError {
251 message: format!(
252 "Mutation '{}' returns '{}', which is not a composite type. A mutation's return type must be an object, interface or union.",
253 mutation.name, base_return
254 ),
255 path: format!("mutations[{idx}].return_type"),
256 severity: ErrorSeverity::Error,
257 suggestion: Some(
258 "Return the entity type the mutation writes, or a union of success and error variants."
259 .to_string(),
260 ),
261 });
262 }
263 } else {
264 report.errors.push(ValidationError {
265 message: format!(
266 "Mutation '{}' references unknown type '{}'",
267 mutation.name, base_return
268 ),
269 path: format!("mutations[{idx}].return_type"),
270 severity: ErrorSeverity::Error,
271 suggestion: Some(Self::suggest_similar_type(base_return, &type_names)),
272 });
273 }
274
275 // Validate argument types
276 for (arg_idx, arg) in mutation.arguments.iter().enumerate() {
277 let base_arg = extract_base_type(&arg.arg_type);
278 if !type_names.contains(base_arg) {
279 report.errors.push(ValidationError {
280 message: format!(
281 "Mutation '{}' argument '{}' references unknown type '{}'",
282 mutation.name, arg.name, base_arg
283 ),
284 path: format!("mutations[{idx}].arguments[{arg_idx}].type"),
285 severity: ErrorSeverity::Error,
286 suggestion: Some(Self::suggest_similar_type(base_arg, &type_names)),
287 });
288 }
289 }
290
291 // Validate sql_source is a safe SQL identifier
292 if let Some(sql_source) = &mutation.sql_source {
293 if let Err(e) = validate_sql_identifier(
294 sql_source,
295 "sql_source",
296 &format!("Mutation.{}", mutation.name),
297 ) {
298 report.errors.push(e);
299 }
300 }
301
302 // Warn about inject_params ordering contract
303 if !mutation.inject.is_empty() {
304 let inject_names: Vec<&str> = mutation.inject.keys().map(String::as_str).collect();
305 let fn_name = mutation.sql_source.as_deref().unwrap_or("<unknown>");
306 report.errors.push(ValidationError {
307 message: format!(
308 "Mutation '{}' has inject params {:?}. \
309 These are appended as the LAST positional arguments to \
310 `{fn_name}`. Your SQL function MUST declare injected \
311 parameters last, after all client-provided arguments.",
312 mutation.name, inject_names,
313 ),
314 path: format!("Mutation.{}", mutation.name),
315 severity: ErrorSeverity::Warning,
316 suggestion: None,
317 });
318 }
319 }
320
321 // Validate observers
322 if let Some(observers) = &schema.observers {
323 let mut observer_names = HashSet::new();
324 for (idx, observer) in observers.iter().enumerate() {
325 debug!("Validating observer: {}", observer.name);
326
327 // Check for duplicate observer names
328 if observer_names.contains(&observer.name) {
329 report.errors.push(ValidationError {
330 message: format!("Duplicate observer name: '{}'", observer.name),
331 path: format!("observers[{idx}].name"),
332 severity: ErrorSeverity::Error,
333 suggestion: Some("Observer names must be unique".to_string()),
334 });
335 }
336 observer_names.insert(observer.name.clone());
337
338 // Validate entity type exists
339 if !type_names.contains(&observer.entity) {
340 report.errors.push(ValidationError {
341 message: format!(
342 "Observer '{}' references unknown entity '{}'",
343 observer.name, observer.entity
344 ),
345 path: format!("observers[{idx}].entity"),
346 severity: ErrorSeverity::Error,
347 suggestion: Some(Self::suggest_similar_type(&observer.entity, &type_names)),
348 });
349 }
350
351 // Validate event type
352 let valid_events = ["INSERT", "UPDATE", "DELETE"];
353 if !valid_events.contains(&observer.event.as_str()) {
354 report.errors.push(ValidationError {
355 message: format!(
356 "Observer '{}' has invalid event '{}'. Must be INSERT, UPDATE, or DELETE",
357 observer.name, observer.event
358 ),
359 path: format!("observers[{idx}].event"),
360 severity: ErrorSeverity::Error,
361 suggestion: Some("Valid events: INSERT, UPDATE, DELETE".to_string()),
362 });
363 }
364
365 // Validate at least one action exists
366 if observer.actions.is_empty() {
367 report.errors.push(ValidationError {
368 message: format!(
369 "Observer '{}' must have at least one action",
370 observer.name
371 ),
372 path: format!("observers[{idx}].actions"),
373 severity: ErrorSeverity::Error,
374 suggestion: Some("Add a webhook, slack, or email action".to_string()),
375 });
376 }
377
378 // Validate each action
379 for (action_idx, action) in observer.actions.iter().enumerate() {
380 if let Some(obj) = action.as_object() {
381 // Check action has a type field
382 if let Some(action_type) = obj.get("type").and_then(|v| v.as_str()) {
383 let valid_action_types = ["webhook", "slack", "email"];
384 if !valid_action_types.contains(&action_type) {
385 report.errors.push(ValidationError {
386 message: format!(
387 "Observer '{}' action {} has invalid type '{}'",
388 observer.name, action_idx, action_type
389 ),
390 path: format!(
391 "observers[{idx}].actions[{action_idx}].type"
392 ),
393 severity: ErrorSeverity::Error,
394 suggestion: Some(
395 "Valid action types: webhook, slack, email".to_string(),
396 ),
397 });
398 }
399
400 // Validate action-specific required fields
401 match action_type {
402 "webhook" => {
403 let has_url = obj.contains_key("url");
404 let has_url_env = obj.contains_key("url_env");
405 if !has_url && !has_url_env {
406 report.errors.push(ValidationError {
407 message: format!(
408 "Observer '{}' webhook action must have 'url' or 'url_env'",
409 observer.name
410 ),
411 path: format!("observers[{idx}].actions[{action_idx}]"),
412 severity: ErrorSeverity::Error,
413 suggestion: Some("Add 'url' or 'url_env' field".to_string()),
414 });
415 }
416 },
417 "slack" => {
418 if !obj.contains_key("channel") {
419 report.errors.push(ValidationError {
420 message: format!(
421 "Observer '{}' slack action must have 'channel' field",
422 observer.name
423 ),
424 path: format!("observers[{idx}].actions[{action_idx}]"),
425 severity: ErrorSeverity::Error,
426 suggestion: Some("Add 'channel' field (e.g., '#sales')".to_string()),
427 });
428 }
429 if !obj.contains_key("message") {
430 report.errors.push(ValidationError {
431 message: format!(
432 "Observer '{}' slack action must have 'message' field",
433 observer.name
434 ),
435 path: format!("observers[{idx}].actions[{action_idx}]"),
436 severity: ErrorSeverity::Error,
437 suggestion: Some("Add 'message' field".to_string()),
438 });
439 }
440 },
441 "email" => {
442 let required_fields = ["to", "subject", "body"];
443 for field in &required_fields {
444 if !obj.contains_key(*field) {
445 report.errors.push(ValidationError {
446 message: format!(
447 "Observer '{}' email action must have '{}' field",
448 observer.name, field
449 ),
450 path: format!("observers[{idx}].actions[{action_idx}]"),
451 severity: ErrorSeverity::Error,
452 suggestion: Some(format!("Add '{field}' field")),
453 });
454 }
455 }
456 },
457 _ => {},
458 }
459 } else {
460 report.errors.push(ValidationError {
461 message: format!(
462 "Observer '{}' action {} missing 'type' field",
463 observer.name, action_idx
464 ),
465 path: format!("observers[{idx}].actions[{action_idx}]"),
466 severity: ErrorSeverity::Error,
467 suggestion: Some(
468 "Add 'type' field (webhook, slack, or email)".to_string(),
469 ),
470 });
471 }
472 } else {
473 report.errors.push(ValidationError {
474 message: format!(
475 "Observer '{}' action {} must be an object",
476 observer.name, action_idx
477 ),
478 path: format!("observers[{idx}].actions[{action_idx}]"),
479 severity: ErrorSeverity::Error,
480 suggestion: None,
481 });
482 }
483 }
484
485 // Validate retry config
486 let valid_backoff_strategies = ["exponential", "linear", "fixed"];
487 if !valid_backoff_strategies.contains(&observer.retry.backoff_strategy.as_str()) {
488 report.errors.push(ValidationError {
489 message: format!(
490 "Observer '{}' has invalid backoff_strategy '{}'",
491 observer.name, observer.retry.backoff_strategy
492 ),
493 path: format!("observers[{idx}].retry.backoff_strategy"),
494 severity: ErrorSeverity::Error,
495 suggestion: Some(
496 "Valid strategies: exponential, linear, fixed".to_string(),
497 ),
498 });
499 }
500
501 if observer.retry.max_attempts == 0 {
502 report.errors.push(ValidationError {
503 message: format!(
504 "Observer '{}' has max_attempts=0, actions will never execute",
505 observer.name
506 ),
507 path: format!("observers[{idx}].retry.max_attempts"),
508 severity: ErrorSeverity::Warning,
509 suggestion: Some("Set max_attempts >= 1".to_string()),
510 });
511 }
512
513 if observer.retry.initial_delay_ms == 0 {
514 report.errors.push(ValidationError {
515 message: format!(
516 "Observer '{}' has initial_delay_ms=0, retries will be immediate",
517 observer.name
518 ),
519 path: format!("observers[{idx}].retry.initial_delay_ms"),
520 severity: ErrorSeverity::Warning,
521 suggestion: Some("Consider setting initial_delay_ms > 0".to_string()),
522 });
523 }
524
525 if observer.retry.max_delay_ms < observer.retry.initial_delay_ms {
526 report.errors.push(ValidationError {
527 message: format!(
528 "Observer '{}' has max_delay_ms < initial_delay_ms",
529 observer.name
530 ),
531 path: format!("observers[{idx}].retry.max_delay_ms"),
532 severity: ErrorSeverity::Error,
533 suggestion: Some("max_delay_ms must be >= initial_delay_ms".to_string()),
534 });
535 }
536 }
537 }
538
539 // Validate sources (#573 scheduled ingress — the dual of observers).
540 if let Some(sources) = &schema.sources {
541 let mut source_names = HashSet::new();
542 let mut cursor_names = HashSet::new();
543 for (idx, source) in sources.iter().enumerate() {
544 // Names key the durable cursor and the advisory lease — they must be unique.
545 if !source_names.insert(source.name.clone()) {
546 report.errors.push(ValidationError {
547 message: format!("Duplicate source name: '{}'", source.name),
548 path: format!("sources[{idx}].name"),
549 severity: ErrorSeverity::Error,
550 suggestion: Some("Source names must be unique".to_string()),
551 });
552 }
553
554 // A shared cursor name would let two sources clobber each other's watermark.
555 let cursor = source.cursor_name().to_string();
556 if !cursor_names.insert(cursor.clone()) {
557 report.errors.push(ValidationError {
558 message: format!(
559 "Source '{}' reuses cursor '{cursor}' already claimed by another source",
560 source.name
561 ),
562 path: format!("sources[{idx}].cursor"),
563 severity: ErrorSeverity::Error,
564 suggestion: Some(
565 "Give each source a distinct cursor (it defaults to the source name)"
566 .to_string(),
567 ),
568 });
569 }
570
571 // The schedule must be a 5-field POSIX cron expression (the runtime parses it
572 // fully).
573 if source.schedule.split_whitespace().count() != 5 {
574 report.errors.push(ValidationError {
575 message: format!(
576 "Source '{}' has an invalid cron schedule '{}': expected 5 \
577 whitespace-separated fields",
578 source.name, source.schedule
579 ),
580 path: format!("sources[{idx}].schedule"),
581 severity: ErrorSeverity::Error,
582 suggestion: Some(
583 "Use a 5-field cron expression, e.g. '*/5 * * * *'".to_string(),
584 ),
585 });
586 }
587
588 // The bound handler must be named.
589 if source.function.trim().is_empty() {
590 report.errors.push(ValidationError {
591 message: format!("Source '{}' has no function", source.name),
592 path: format!("sources[{idx}].function"),
593 severity: ErrorSeverity::Error,
594 suggestion: Some("Set the handler function name".to_string()),
595 });
596 }
597
598 // The `run_as` authority ceiling (#573 D6): a blank grant is a config
599 // error, and an empty ceiling is fail-closed (the source can write
600 // nothing) — valid, but almost always a mistake, so warn.
601 if let Some(run_as) = &source.run_as {
602 let has_blank = run_as
603 .roles
604 .iter()
605 .chain(run_as.scopes.iter())
606 .any(|grant| grant.trim().is_empty())
607 || run_as.tenant.as_ref().is_some_and(|t| t.trim().is_empty());
608 if has_blank {
609 report.errors.push(ValidationError {
610 message: format!(
611 "Source '{}' has a blank role, scope, or tenant in run_as",
612 source.name
613 ),
614 path: format!("sources[{idx}].run_as"),
615 severity: ErrorSeverity::Error,
616 suggestion: Some(
617 "Remove empty entries; each role/scope/tenant must be non-blank"
618 .to_string(),
619 ),
620 });
621 }
622 if run_as.roles.is_empty() && run_as.scopes.is_empty() {
623 report.errors.push(ValidationError {
624 message: format!(
625 "Source '{}' declares run_as with no authority (no roles or \
626 scopes): its mutations will be denied (fail-closed)",
627 source.name
628 ),
629 path: format!("sources[{idx}].run_as"),
630 severity: ErrorSeverity::Warning,
631 suggestion: Some(
632 "Grant the source the least-privilege roles/scopes its mutations \
633 need, or drop run_as if it never mutates"
634 .to_string(),
635 ),
636 });
637 }
638 }
639 }
640 }
641
642 info!(
643 "Validation complete: {} errors, {} warnings",
644 report.error_count(),
645 report.warning_count()
646 );
647
648 Ok(report)
649 }
650
651 /// Suggest similar type names for a typo.
652 ///
653 /// Delegates ranking to [`fraiseql_core::runtime::suggest_similar`], a real edit-distance
654 /// match over `char`s. What this replaces did two things wrong at once:
655 ///
656 /// * It sliced `&typo[0..1]` and `&name[0..1]` — **byte** ranges — so an empty base type
657 /// (`"return_type": ""`) or any name beginning with a multi-byte character *panicked the CLI
658 /// while it was composing an error message*. The panic was documented in a `# Panics` section
659 /// that no caller honoured (#724 item 1).
660 /// * It matched on first letter only, behind a comment calling itself "Levenshtein-style". For
661 /// a typo of `User` it offered `Universe` and `Umbrella`.
662 ///
663 /// Deterministic output: `available` is a `HashSet`, so candidates are sorted before
664 /// ranking and the fallback list is sorted too. Without that, the same schema produced
665 /// different error text run to run.
666 fn suggest_similar_type(typo: &str, available: &HashSet<String>) -> String {
667 let mut candidates: Vec<&str> = available.iter().map(String::as_str).collect();
668 candidates.sort_unstable();
669
670 let similar = fraiseql_core::runtime::suggest_similar(typo, &candidates);
671 if similar.is_empty() {
672 format!("Available types: {}", candidates.join(", "))
673 } else {
674 format!("Did you mean: {}?", similar.join(", "))
675 }
676 }
677}