fix(drivers): flip runtime tag parsers to strict enum parsing (#457)
v2-ci / build (pull_request) Successful in 4m12s
v2-ci / unit-tests (pull_request) Failing after 10m15s

R2-11 Phase C. All six equipment-tag parsers (Modbus/S7/AbCip/AbLegacy/
TwinCAT/Focas) now read enum fields via the new TagConfigJson.TryReadEnumStrict:
absent -> fallback, valid -> parsed, present-but-invalid (typo) -> TryParse
returns false -> EquipmentTagRefResolver.TryResolve false -> driver surfaces
BadNodeIdUnknown, instead of the old lenient path that silently defaulted a
typo to a wrong-width Good.

Modbus flips all three enum fields (region/dataType/byteOrder); the other five
flip dataType. The deploy-time Deployment:TagConfigValidationMode=Error gate is
unchanged and remains the operator pre-flight.

Coverage:
- Six *EquipmentTagParserStrictnessTests inverted from Freeze_typo_* (lenient)
  to Typo_*_rejects_the_tag + Valid_*_still_parses.
- TagConfigJsonTests.TryReadEnumStrict_rejects_only_invalid matrix.
- Driver-level end-to-end proof:
  AbCipEquipmentTagTests.Driver_read_of_a_typod_dataType_ref_surfaces_BadNodeIdUnknown
  drives the real AbCipDriver.ReadAsync through resolve->status.

Golden parity corpus has no typo'd enums, so the flip is a no-op there.
Full solution builds clean; all six driver suites + core + parity + gate green.
This commit is contained in:
Joseph Doherty
2026-07-15 11:28:34 -04:00
parent 57003a23a5
commit e8db0a683f
16 changed files with 193 additions and 38 deletions
@@ -68,6 +68,35 @@ public static class TagConfigJson
where TEnum : struct, Enum
=> TryReadEnum<TEnum>(o, name, out var v) == JsonEnumRead.Valid ? v : fallback;
/// <summary>
/// Strict enum read (R2-11 Phase C — the runtime flip). Absent ⇒ <paramref name="fallback"/>;
/// a present, case-insensitively-parseable string ⇒ the parsed value; a present-but-invalid
/// string (a typo) ⇒ the read FAILS (returns <see langword="false"/>) so the runtime parser can
/// reject the whole tag and the driver surfaces <c>BadNodeIdUnknown</c> — instead of silently
/// substituting a wrong-width default and publishing a misleading <c>Good</c>. This is the runtime
/// counterpart of <see cref="ReadEnumOrDefault{TEnum}"/>: identical absent/valid handling, but
/// <see cref="JsonEnumRead.Invalid"/> is fatal rather than defaulted. The deploy-time gate
/// (<c>TagConfigValidationMode.Error</c>) is expected to have already surfaced the same typo via
/// <see cref="DescribeInvalidEnum{TEnum}"/>; a typo reaching this fatal path means it was authored
/// after the gate, or the gate ran in Warn mode.
/// </summary>
/// <typeparam name="TEnum">The enum type to parse.</typeparam>
/// <param name="o">The TagConfig root object.</param>
/// <param name="name">The property name to read.</param>
/// <param name="fallback">The value assigned when the field is absent.</param>
/// <param name="value">The parsed enum (valid), the fallback (absent), or <c>default</c> (invalid).</param>
/// <returns><see langword="false"/> only when the field is a present-but-invalid string; otherwise <see langword="true"/>.</returns>
public static bool TryReadEnumStrict<TEnum>(JsonElement o, string name, TEnum fallback, out TEnum value)
where TEnum : struct, Enum
{
switch (TryReadEnum<TEnum>(o, name, out value))
{
case JsonEnumRead.Valid: return true;
case JsonEnumRead.Absent: value = fallback; return true;
default: value = default; return false; // Invalid (typo) ⇒ reject the tag
}
}
/// <summary>
/// Reads the optional <c>"writable"</c> flag with AbCip semantics (the model): an explicit JSON
/// <c>false</c> ⇒ <see langword="false"/>; anything else (absent, <c>true</c>, or a non-bool token)