validateModel function

({List<String> errors, List<String> warnings}) validateModel(
  1. Map<String, ModelClass> classes,
  2. String rootTypeName
)

Validates model classes against the tom_specs_model_rules.md §5 design rules.

Returns a record of (errors, warnings). Errors prevent output; warnings are reported but don't block generation.

Also runs validateStructuralInvariants for the tom_specs_model_rules.md §10.2 structural checks whenever D00SolutionBlueprint is present in classes.

Implementation

({List<String> errors, List<String> warnings}) validateModel(
  Map<String, ModelClass> classes,
  String rootTypeName,
) {
  final errors = <String>[];
  final warnings = <String>[];

  // `tom_specs_model_rules.md` §5.7 — find reachable types from root
  final reachable = _findReachableTypes(classes, rootTypeName);

  // --- `tom_specs_model_rules.md` §10.2 invariant MEMBER-SHAPE: member-shape legality, @ContentType
  // compatibility, cycle detection. The one `tom_specs_model_rules.md` §10.2 rule enforced here rather than in
  // [validateStructuralInvariants]: it is a per-class shape rule that needs no
  // SBP tree, so it runs for every root the generator is pointed at, including
  // the synthetic fixtures that have no `D00SolutionBlueprint`.
  //
  // The canonical container (V2, N9) is the structural tree root, not a content
  // section: it owns no `content` and carries no `@SectionId`. Exempt it from
  // the `tom_specs_model_rules.md` §5 per-class content/field checks when it
  // is reachable (T1).
  final containerRoot = findContainerRoot(classes);

  for (final className in reachable) {
    final cls = classes[className];
    if (cls == null) continue;
    if (className == containerRoot) continue;

    // `tom_specs_model_rules.md` §5.2 / §5.4 — every section class re-declares
    // `content: String?` with `@override`, so a section can carry its own prose
    // regardless of which form fields it also has. Only the container root (T1,
    // exempted above) may omit it.
    final hasContent = cls.fields.any((f) => f.name == 'content');
    if (!hasContent) {
      errors.add('$className: missing field "content: String?"');
    }

    for (final field in cls.fields) {
      // `tom_specs_model_rules.md` §5.1 — list fields. Shape (5) uses
      // `List<ComplexType>`; shape (6) is an
      // inline list of content sub-sections written as `List<String>` carrying
      // both `@SectionId` (the `-LST` container id) and `@SectionIdPattern`. A
      // `List<String>` (or `List<primitive>`) WITHOUT that annotated pair is a
      // bare primitive list and remains an error.
      if (field.isList) {
        final inner = field.listElementTypeName ?? '';
        // YRD5: `List<DocSpecsSection>` replaces `List<String>` as the inline
        // content sub-section list shape — same annotated-pair requirement.
        if (_isPrimitive(inner) || field.listElementIsContentSection) {
          final isInlineContentList =
              (inner == 'String' || field.listElementIsContentSection) &&
              field.getAnnotation('SectionId') != null &&
              field.getAnnotation('SectionIdPattern') != null;
          if (!isInlineContentList) {
            errors.add(
              '$className.${field.name}: List<$inner> not allowed — '
              'list fields must use complex types, or (for List<String>) carry '
              '@SectionId + @SectionIdPattern as an inline content sub-section '
              'list (`tom_specs_model_rules.md` §5.1 shape 6)',
            );
          }
        }
      }

      // `tom_specs_model_rules.md` §5.4 — no primitive non-String scalars
      if (!field.isList &&
          !field.isEnum &&
          _isNonStringPrimitive(field.typeName)) {
        errors.add(
          '$className.${field.name}: type "${field.typeName}" not allowed — '
          'use String? with @Form Field type parameter instead',
        );
      }

      // `tom_specs_model_rules.md` §5.1 (YRB1) — canonical field shapes. The
      // name `content` is reserved
      // for the section's OWN content (shapes (1)/(2)): its section id comes
      // from the class, so it must be a String and must NOT carry a
      // field-level @SectionId. Every OTHER String field is an inline
      // sub-section (shape (3)) and MUST carry a field-level @SectionId.
      if (field.name == 'content') {
        if (!field.isString) {
          errors.add(
            '$_shapes field-shape: $className.content — the reserved field name '
            '"content" is only valid for the section\'s own String content, '
            'but its type is "${field.typeName}"',
          );
        } else if (field.getAnnotation('SectionId') != null) {
          errors.add(
            '$_shapes field-shape: $className.content — the section\'s own '
            '"content" must not carry a field-level @SectionId; its id comes '
            'from the class',
          );
        }
      } else if (field.isContentLike &&
          field.getAnnotation('SectionId') == null) {
        // YRD5: inline sub-sections are `DocSpecsSection?` members (formerly
        // `String?`); both shapes must carry the field-level @SectionId.
        errors.add(
          '$_shapes field-shape: $className.${field.name} — a non-"content" '
          'section field must carry a field-level @SectionId (it is an inline '
          'sub-section), or be named "content" if it is the section\'s own '
          'content',
        );
      }
    }

    // `tom_specs_model_rules.md` §5.6 — ContentType constraints
    final contentField = cls.fields
        .where((f) => f.name == 'content')
        .firstOrNull;
    if (contentField != null) {
      final contentTypeAnno = contentField.getAnnotation('ContentType');
      final contentType =
          contentTypeAnno?.arguments['type'] as String? ?? 'Form';
      if (contentType != 'Form') {
        // Non-Form content — class must not have other scalar fields
        final otherScalars = cls.fields
            .where((f) => f.name != 'content' && f.isLeaf)
            .toList();
        if (otherScalars.isNotEmpty) {
          final names = otherScalars.map((f) => f.name).join(', ');
          errors.add(
            '$className: @ContentType("$contentType") prohibits other scalar '
            'fields, but found: $names',
          );
        }
      }

      // `tom_specs_model_rules.md` §5.6 — every `content` field is documented.
      // One of four annotations says what an author puts there: `@ContentHelp`
      // (authoring guidance), `@ContentType` (the text's format plus its
      // mandatory description), `@Form` (the content is a packed form), or
      // `@Unused` (the class is a pure container and owns no prose). Without
      // one, the editor shows a section whose only guidance is its headline.
      final documentsContent =
          contentTypeAnno != null ||
          contentField.getAnnotation('ContentHelp') != null ||
          contentField.getAnnotation('Unused') != null ||
          contentField.getAnnotation('Form') != null ||
          contentField.formFields.isNotEmpty;
      if (!documentsContent) {
        errors.add(
          '$className.content: undocumented — a `content` field must carry '
          '@ContentHelp, @ContentType, @Form or @Unused '
          '(`tom_specs_model_rules.md` §5.6)',
        );
      }
    }
  }

  // YRD5 — once the model has adopted the DocSpecsSection base class, EVERY
  // model class must extend it (tom_specs_model_rules.md §5.2). The
  // check only activates when at least one class extends the base, so
  // synthetic test fixtures that predate YRD5 keep validating.
  if (classes.values.any((c) => c.extendsDocSpecsSection)) {
    for (final className in reachable) {
      final cls = classes[className];
      if (cls == null) continue;
      if (!cls.extendsDocSpecsSection) {
        errors.add(
          'YRD5: $className must extend DocSpecsSection — every model class '
          'is a section (tom_specs_model_rules.md §5.2)',
        );
      }
    }
  }

  // YRD7 — a form field's type must be a supported scalar: a primitive
  // (String/int/double/num/bool) or a model enum (resolved to its constant
  // names at read time). Anything else cannot get a typed accessor or a
  // plain-text `FieldName: value` serialization.
  void checkFormFieldTypes(String where, List<FormFieldInfo> fields) {
    for (final ff in fields) {
      const primitives = {'String', 'int', 'double', 'num', 'bool'};
      final base = ff.typeName.endsWith('?')
          ? ff.typeName.substring(0, ff.typeName.length - 1)
          : ff.typeName;
      if (!primitives.contains(base) && ff.enumValues.isEmpty) {
        errors.add(
          'YRD7 form-field type: $where — field "${ff.name}" has unsupported '
          'type "${ff.typeName}"; form fields must be String/int/double/num/'
          'bool or a model enum',
        );
      }
    }
  }

  for (final className in reachable) {
    final cls = classes[className];
    if (cls == null) continue;
    if (cls.formFields.isNotEmpty) {
      checkFormFieldTypes('$className (class-level @Form)', cls.formFields);
    }
    for (final field in cls.fields) {
      if (field.formFields.isNotEmpty) {
        checkFormFieldTypes('$className.${field.name}', field.formFields);
      }
    }
  }

  // `tom_specs_model_rules.md` §5.7 — cycle detection
  final cycleError = _detectCycles(classes, rootTypeName);
  if (cycleError != null) {
    errors.add(cycleError);
  }

  // `tom_specs_model_rules.md` §10.2 — structural invariants (SBP-global, runs
  // whenever D00SolutionBlueprint is present in the classes map regardless of
  // the current root type).
  _validateStructuralInvariants(classes, errors, warnings);

  return (errors: errors, warnings: warnings);
}