validateDocument function

List<SpecValidationError> validateDocument(
  1. SpecModel model,
  2. SpecDocument doc
)

Validates doc against model. Returns an empty list when the document is valid; otherwise one SpecValidationError per problem, in a stable order (content paths, then forms, then lists; each group sorted by path).

Implementation

List<SpecValidationError> validateDocument(SpecModel model, SpecDocument doc) {
  final refl = SpecReflection(model);
  final errors = <SpecValidationError>[];

  List<String> sorted(Iterable<String> keys) => keys.toList()..sort();

  // 1. Content/scalar/enum leaves.
  for (final path in sorted(doc.contentPaths)) {
    final res = refl.resolve(path);
    if (res == null) {
      errors.add(_dangling(path));
      continue;
    }
    // A form node is the one non-leaf that legitimately carries content: it is
    // the form's preamble, the free text before the first field line (SOM §11.4
    // rule 7), stored in the same `content` slot a plain section's body uses.
    // A wrapper section (`TextSection`, …) is the other: its class is not
    // exported, so it resolves to no class and its text is stored at its own
    // path, the way the YAML codec writes it (SpecFieldKind.section).
    final isWrapperSection =
        res.kind == SpecNodeKind.section && res.targetClass == null;
    if (!res.isValueLeaf &&
        res.kind != SpecNodeKind.form &&
        !isWrapperSection) {
      errors.add(
        SpecValidationError(
          path: path,
          code: SpecValidationCode.kindMismatch,
          message:
              'expected a value leaf but path resolves to ${res.kind.name}',
        ),
      );
      continue;
    }
    if (res.kind == SpecNodeKind.scalar) {
      final type = res.field?.type;
      final value = doc.content(path);
      if (!somTypedTextReadable(type, value)) {
        errors.add(
          SpecValidationError(
            path: path,
            code: SpecValidationCode.valueTypeMismatch,
            message:
                'holds "$value", which is not readable as '
                '${somScalarBase(type)}',
          ),
        );
      }
    }
  }

  // 2. Form sections.
  for (final path in sorted(doc.formPaths)) {
    final res = refl.resolve(path);
    if (res == null) {
      errors.add(_dangling(path));
      continue;
    }
    if (res.kind != SpecNodeKind.form || res.field == null) {
      errors.add(
        SpecValidationError(
          path: path,
          code: SpecValidationCode.kindMismatch,
          message:
              'expected a form section but path resolves to ${res.kind.name}',
        ),
      );
      continue;
    }
    final declared = {for (final ff in res.field!.formFields) ff.name: ff};
    for (final name in sorted(doc.formFieldNames(path))) {
      final spec = declared[name];
      if (spec == null) {
        errors.add(
          SpecValidationError(
            path: path,
            code: SpecValidationCode.unknownFormField,
            message: 'form field "$name" is not declared on ${res.field!.name}',
          ),
        );
        continue;
      }
      if (spec.enumValues.isEmpty) {
        final text = doc.formField(path, name);
        // A stated count (`countOf`) is checked by `countMismatch`, which
        // already reports text that is no whole number; one finding is enough.
        if (spec.countOf.isEmpty &&
            spec.productOf.isEmpty &&
            spec.rankOf == null &&
            !somTypedTextReadable(spec.type, text)) {
          errors.add(
            SpecValidationError(
              path: path,
              code: SpecValidationCode.valueTypeMismatch,
              message:
                  'form field "$name" holds "$text", which is not readable '
                  'as ${somScalarBase(spec.type)}',
            ),
          );
        }
        continue;
      }
      final value = doc.formField(path, name);
      // Absence is not a bad value: an empty field is what every other tier
      // reads as "not set", and reporting it here would make a half-filled
      // document unloadable rather than incomplete.
      if (value == null || value.isEmpty) continue;
      if (!spec.enumValues.contains(value)) {
        errors.add(
          SpecValidationError(
            path: path,
            code: SpecValidationCode.enumValueUnknown,
            message:
                'form field "$name" holds "$value", which ${spec.type} does '
                'not declare (${spec.enumValues.join(', ')})',
          ),
        );
      }
    }
    // A form the document holds must fill its required fields; the declared
    // order makes the findings for one form stable.
    final slash = path.lastIndexOf('/');
    final owner = slash > 0
        ? refl.resolve(path.substring(0, slash))?.targetClass?.name
        : null;
    final formName = owner == null
        ? res.field!.name
        : '$owner.${res.field!.name}';
    for (final ff in res.field!.formFields) {
      if (!ff.required) continue;
      final value = doc.formField(path, ff.name)?.trim();
      if (value == null || value.isEmpty) {
        errors.add(
          SpecValidationError(
            path: path,
            code: SpecValidationCode.missingRequiredField,
            message:
                'form field "${ff.name}" is required on $formName but empty',
          ),
        );
      }
    }
  }

  // 3. Lists (container kind + `@Min` count on populated lists).
  for (final path in sorted(doc.listPaths)) {
    final res = refl.resolve(path);
    if (res == null) {
      errors.add(_dangling(path));
      continue;
    }
    if (res.kind != SpecNodeKind.list || res.field == null) {
      errors.add(
        SpecValidationError(
          path: path,
          code: SpecValidationCode.kindMismatch,
          message: 'expected a list but path resolves to ${res.kind.name}',
        ),
      );
      continue;
    }
    final min = res.field!.min;
    final count = doc.listItemCount(path);
    if (min != null && count < min) {
      errors.add(
        SpecValidationError(
          path: path,
          code: SpecValidationCode.minItems,
          message: 'list holds $count item(s) but requires at least $min',
        ),
      );
    }
  }

  // 4. @OneOf discriminated subsection groups (instance tier, csmb6).
  //
  // A concrete `@OneOf` container must carry ONLY the subsections whose `@Case`
  // matches the chosen discriminator value (plus the common, un-`@Case`d ones),
  // and at most one case subsection for the chosen case
  // (`codespecs_mapping.md` §8.2). The static tier (validator.dart) has
  // already checked the annotations are well-formed; here we check a document's
  // *values* against them.
  errors.addAll(_validateOneOfInstances(refl, doc));

  // 5. Cross-registry id references (instance tier, csrb3).
  //
  // A reference form field holds an id that must already be declared by some
  // entry of a target registry. The static tier (validator.dart) has checked
  // the `refersTo` targets are resolvable; only here can we see whether the id
  // a document actually wrote is one the document also declares.
  errors.addAll(_validateReferenceInstances(refl, doc));

  // 6. Stated counts (instance tier, vsctsa43).
  //
  // A form field declaring `countOf` states how many entries the document
  // holds; only the instance tier can count them.
  errors.addAll(_validateCountInstances(refl, doc));
  errors.addAll(_validateDerivedValues(refl, doc));

  // 7. Review annotations (SOM §11.9.5): malformed, misplaced or duplicated
  // comment blocks, question blocks and inline marks. Findings, never parse
  // rejections — the rest of the document still validates.
  errors.addAll(SpecReviewAnnotations(doc).validate());

  return errors;
}