config: multi-error YAML validator + --validate-config CLI flag

The existing loader throws ConfigError on the first problem it hits.
A customer with a tool-specific equipment.yaml that has six issues
sees one, fixes, restarts, sees the next, fixes, restarts — six
edit-restart cycles before the server even binds.  Day-1 friction
is the top support ticket source in fab integrations.

This commit adds a parallel validator that does a separate read-only
pass and surfaces *every* issue at once:

  $ secs_server --validate-config \
      --config equipment.yaml \
      --state-table control_state.yaml
  [error] equipment.yaml:5  svids[0].type — unknown SECS-II type `WTF`
  [error] equipment.yaml:7  alarms[0].category — value 200 out of range [0, 127]
  [error] equipment.yaml:9  host_commands[0].emit_ceid — CEID 999 not declared in `ceids` section
  3 error(s), 0 warning(s) across 4 files

What it catches:
- Missing required fields (device.model_name, .software_rev, …)
- Range violations (alarm category must be 0–127, spool streams 1–127,
  device.id fits u16, etc.)
- Unknown enum values (SECS-II types, HCACK values, control/PJ/CJ
  state and event names — using the right case + snake convention
  the runtime parsers enforce)
- Duplicate IDs within svids / dvids / ecids / ceids / alarms,
  duplicate PPIDs in recipes, duplicate command names in host_commands
- Referential integrity: host_commands[*].emit_ceid must exist in
  ceids; host_commands[*].set_alarm must exist in alarms;
  emit_on_control_change must exist in ceids
- PJ-table-specific: `NoState` sentinel rejected as `initial`,
  `from`, or `to` (matches loader's existing runtime check)
- yaml-cpp Mark → 1-based line numbers when available

What it doesn't catch (out of scope this round):
- JSON Schema for editor red-squigglies (future)
- Deep semantic checks across state-table reachability
- ECID min/max value parsing (would need numeric type coupling)

Tests cover: clean file passes; multi-error YAML surfaces every issue
on a single pass; line numbers populate; control_state /
process_job_state / control_job_state casing conventions;
format_issues_to renders both severities; the shipped
data/equipment.yaml etc. validate cleanly (regression tripwire if
anyone breaks the demo configs).

INTEGRATION.md §2.3 calls out the flag and suggests CI use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 14:32:09 +02:00
parent d73906f372
commit a4599b3b9d
6 changed files with 833 additions and 5 deletions
+29 -4
View File
@@ -13,6 +13,7 @@
#include <vector>
#include "secsgem/config/loader.hpp"
#include "secsgem/config/validate.hpp"
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
@@ -35,6 +36,12 @@ std::string arg(int argc, char** argv, const std::string& key, const std::string
return def;
}
bool has_flag(int argc, char** argv, const std::string& key) {
for (int i = 1; i < argc; ++i)
if (key == argv[i]) return true;
return false;
}
constexpr uint32_t kSvidControlState = 1;
constexpr uint32_t kSvidClock = 2;
@@ -49,10 +56,30 @@ int main(int argc, char** argv) {
const auto port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
const auto equipment_yaml = arg(argc, argv, "--config", "/app/data/equipment.yaml");
const auto state_yaml = arg(argc, argv, "--state-table", "/app/data/control_state.yaml");
const auto pj_state_yaml = arg(argc, argv, "--pj-state-table",
"/app/data/process_job_state.yaml");
const auto cj_state_yaml = arg(argc, argv, "--cj-state-table",
"/app/data/control_job_state.yaml");
const auto spool_dir = arg(argc, argv, "--spool-dir", "");
const bool validate_only = has_flag(argc, argv, "--validate-config");
auto logfn = [](const std::string& m) { std::cout << "[equip] " << m << std::endl; };
// --validate-config: read every YAML, accumulate every issue we can
// find, print to stderr, and exit 0/1. Does NOT bind the port — this
// is the day-1 friction killer the README points customers at.
if (validate_only) {
config::ConfigValidator v;
v.validate_equipment(equipment_yaml);
v.validate_control_state(state_yaml);
v.validate_process_job_state(pj_state_yaml);
v.validate_control_job_state(cj_state_yaml);
config::format_issues_to(std::cerr, v.issues());
std::cerr << v.error_count() << " error(s), "
<< v.warning_count() << " warning(s) across 4 files\n";
return v.has_errors() ? 1 : 0;
}
auto model = std::make_shared<gem::EquipmentDataModel>();
if (!spool_dir.empty()) {
model->spool.enable_persistence(spool_dir);
@@ -150,10 +177,8 @@ int main(int argc, char** argv) {
});
// ---- E40/E94: load the PJ/CJ transition tables and wire emitters -----
const auto pj_state_yaml = arg(argc, argv, "--pj-state-table",
"/app/data/process_job_state.yaml");
const auto cj_state_yaml = arg(argc, argv, "--cj-state-table",
"/app/data/control_job_state.yaml");
// (pj_state_yaml / cj_state_yaml already parsed at the top so
// --validate-config sees them.)
config::ProcessJobStateConfig pj_cfg;
config::ControlJobStateConfig cj_cfg;
try {