validateDocument function
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;
}