validateModel function
({List<String> errors, List<String> warnings})
validateModel(
- Map<
String, ModelClass> classes, - 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);
}