first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""State machine tooling package (validate + render)."""
|
||||
@@ -0,0 +1,376 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Edge:
|
||||
src: str
|
||||
dst: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Node:
|
||||
node_id: str
|
||||
label: str
|
||||
kind: str # state | pseudo | event
|
||||
shape: str = "rect" # rect | rounded | pseudo
|
||||
|
||||
|
||||
def _format_guard_value(v: Any) -> str:
|
||||
if v is True:
|
||||
return "true"
|
||||
if v is False:
|
||||
return "false"
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, (int, float)):
|
||||
return str(v)
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
if isinstance(v, list):
|
||||
# Keep it readable; lists are rare in guards.
|
||||
return "[" + ",".join(_format_guard_value(x) for x in v[:6]) + ("]" if len(v) <= 6 else ",…]")
|
||||
return str(v)
|
||||
|
||||
|
||||
def _guard_summary(when: Mapping[str, Any], *, max_len: int = 42) -> str:
|
||||
parts: List[str] = []
|
||||
for k in sorted(when.keys()):
|
||||
parts.append(f"{k}={_format_guard_value(when[k])}")
|
||||
s = " ".join(parts)
|
||||
if len(s) > max_len:
|
||||
return s[:max_len] + "…"
|
||||
return s
|
||||
|
||||
|
||||
def _shorten(s: str, max_len: int) -> str:
|
||||
s = s.strip().replace("\n", " ")
|
||||
if len(s) <= max_len:
|
||||
return s
|
||||
return s[:max_len] + "…"
|
||||
|
||||
|
||||
def _desc_prefix(desc: str, *, max_len: int) -> str:
|
||||
"""Pick a high-signal prefix for edge labels.
|
||||
|
||||
We prefer the text before common separators so the main meaning appears first.
|
||||
"""
|
||||
s = desc.strip().replace("\n", " ")
|
||||
for sep in (":", "→", "->", "(", "(", ",", ",", ";", "。"):
|
||||
if sep in s:
|
||||
head = s.split(sep, 1)[0].strip()
|
||||
if 4 <= len(head) <= max_len:
|
||||
return head
|
||||
return _shorten(s, max_len)
|
||||
|
||||
|
||||
def _branch_suffix(i: int) -> str:
|
||||
# a, b, c ...; if too many, fall back to xN.
|
||||
if 0 <= i < 26:
|
||||
return chr(ord("a") + i)
|
||||
return f"x{i + 1}"
|
||||
|
||||
|
||||
def _event_outcome_tag(event_id: str, when: Mapping[str, Any]) -> str:
|
||||
"""Optional human-friendly tag appended to an edge label.
|
||||
|
||||
Keep it minimal and only for high-value cases to avoid clutter.
|
||||
"""
|
||||
if event_id == "E16":
|
||||
# Auto-bind: show the effective matching shape (only the two requested ones).
|
||||
outcome = when.get("auto_bind_outcome")
|
||||
if outcome == "ONE_TO_ONE":
|
||||
return "(1:1)"
|
||||
if outcome == "ZERO":
|
||||
return "(N:0)"
|
||||
return ""
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError("Top-level YAML must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _get_state_status(states: Mapping[str, Any], state_id: str) -> Optional[str]:
|
||||
s = states.get(state_id)
|
||||
if not isinstance(s, dict):
|
||||
return None
|
||||
status = s.get("status")
|
||||
return status if isinstance(status, str) else None
|
||||
|
||||
|
||||
def _iter_to_map_targets(states: Mapping[str, Any], from_state: str, to_map: Mapping[str, Any]) -> Set[str]:
|
||||
key = to_map.get("key")
|
||||
mp = to_map.get("map")
|
||||
if key != "self.status" or not isinstance(mp, dict):
|
||||
# Unsupported mapping; fall back to all targets
|
||||
return {v for v in mp.values()} if isinstance(mp, dict) else set()
|
||||
|
||||
status = _get_state_status(states, from_state)
|
||||
if status and status != "*" and status in mp and isinstance(mp[status], str):
|
||||
return {mp[status]}
|
||||
|
||||
# Wildcard or unknown status: return all possible targets
|
||||
targets: Set[str] = set()
|
||||
for v in mp.values():
|
||||
if isinstance(v, str):
|
||||
targets.add(v)
|
||||
return targets
|
||||
|
||||
|
||||
def _collect_state_edges(cfg: Mapping[str, Any]) -> List[Edge]:
|
||||
states = cfg.get("states") if isinstance(cfg.get("states"), dict) else {}
|
||||
transitions = cfg.get("transitions") if isinstance(cfg.get("transitions"), dict) else {}
|
||||
|
||||
# Aggregate multiple transitions between the same two states.
|
||||
edge_events: Dict[Tuple[str, str], Set[str]] = {}
|
||||
|
||||
for tid, tdef in transitions.items():
|
||||
if not isinstance(tdef, dict):
|
||||
continue
|
||||
frm = tdef.get("from")
|
||||
if not isinstance(frm, list) or not frm:
|
||||
continue
|
||||
|
||||
outcomes = tdef.get("outcomes")
|
||||
if not isinstance(outcomes, list) or not outcomes:
|
||||
continue
|
||||
|
||||
tid_str = str(tid)
|
||||
desc = tdef.get("desc")
|
||||
desc_short = _desc_prefix(desc, max_len=16) if isinstance(desc, str) and desc else ""
|
||||
|
||||
for src in frm:
|
||||
if not isinstance(src, str):
|
||||
continue
|
||||
if src == "*":
|
||||
# For visualization, we don't expand "*" because it explodes.
|
||||
continue
|
||||
|
||||
src_action: Optional[str] = None
|
||||
src_state = states.get(src)
|
||||
if isinstance(src_state, dict):
|
||||
action_val = src_state.get("action")
|
||||
if isinstance(action_val, str):
|
||||
src_action = action_val
|
||||
|
||||
for oi, o in enumerate(outcomes):
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
|
||||
when = o.get("when")
|
||||
if not isinstance(when, dict):
|
||||
when = {}
|
||||
|
||||
# Respect execute_action guard in graph generation so we don't
|
||||
# draw branches that are impossible for the source state's action.
|
||||
if src_action is not None and "execute_action" in when:
|
||||
guarded_action = when.get("execute_action")
|
||||
if isinstance(guarded_action, str) and src_action != guarded_action:
|
||||
continue
|
||||
if isinstance(guarded_action, list) and all(isinstance(v, str) for v in guarded_action):
|
||||
if src_action not in guarded_action:
|
||||
continue
|
||||
|
||||
targets: Set[str] = set()
|
||||
if "to" in o and isinstance(o.get("to"), str):
|
||||
targets.add(o["to"])
|
||||
elif "to_map" in o and isinstance(o.get("to_map"), dict):
|
||||
tm = o["to_map"]
|
||||
if tm.get("key") == "self.status" and isinstance(tm.get("map"), dict):
|
||||
for status_key, dst_state in tm["map"].items():
|
||||
if isinstance(dst_state, str) and isinstance(status_key, str):
|
||||
targets.add(dst_state)
|
||||
continue
|
||||
targets |= _iter_to_map_targets(states, src, tm)
|
||||
|
||||
for dst in sorted(targets):
|
||||
label = f"{tid_str}{_branch_suffix(oi)}"
|
||||
if desc_short:
|
||||
tag = _event_outcome_tag(tid_str, when)
|
||||
label = f"{label} {desc_short}{tag}"
|
||||
edge_events.setdefault((src, dst), set()).add(label)
|
||||
|
||||
edges: List[Edge] = []
|
||||
for (src, dst), tids in sorted(edge_events.items()):
|
||||
label = ", ".join(sorted(tids))
|
||||
edges.append(Edge(src=src, dst=dst, label=label))
|
||||
return edges
|
||||
|
||||
|
||||
def _iter_state_nodes(cfg: Mapping[str, Any]) -> List[Node]:
|
||||
nodes: List[Node] = []
|
||||
states = cfg.get("states") if isinstance(cfg.get("states"), dict) else {}
|
||||
pseudo = cfg.get("pseudo_states") if isinstance(cfg.get("pseudo_states"), dict) else {}
|
||||
|
||||
for sid, sdef in states.items():
|
||||
if not isinstance(sdef, dict):
|
||||
continue
|
||||
desc = sdef.get("desc")
|
||||
label = sid
|
||||
if isinstance(desc, str) and desc:
|
||||
label = f"{sid}\\n{desc}"
|
||||
shape = "rect"
|
||||
if sdef.get("shape") == "rounded":
|
||||
shape = "rounded"
|
||||
elif sdef.get("shape") == "pseudo":
|
||||
shape = "pseudo"
|
||||
nodes.append(Node(node_id=sid, label=label, kind="state", shape=shape))
|
||||
|
||||
for pid, pdef in pseudo.items():
|
||||
label = pid
|
||||
if isinstance(pdef, dict) and isinstance(pdef.get("desc"), str) and pdef.get("desc"):
|
||||
label = f"{pid}\\n{pdef['desc']}"
|
||||
nodes.append(Node(node_id=pid, label=label, kind="pseudo", shape="pseudo"))
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
def render_dot(cfg: Mapping[str, Any]) -> str:
|
||||
nodes = _iter_state_nodes(cfg)
|
||||
edges = _collect_state_edges(cfg)
|
||||
node_ids = {n.node_id for n in nodes}
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("digraph NodeStateMachine {")
|
||||
lines.append(" rankdir=LR;")
|
||||
lines.append(" node [fontname=\"Consolas\"];\n")
|
||||
|
||||
for n in nodes:
|
||||
safe_label = n.label.replace('"', "\\\"")
|
||||
shape = "box"
|
||||
attrs: List[str] = [f"label=\"{safe_label}\"", f"shape={shape}"]
|
||||
if n.kind == "pseudo":
|
||||
attrs = [f"label=\"{safe_label}\"", "shape=oval"]
|
||||
elif n.shape == "pseudo":
|
||||
attrs = [f"label=\"{safe_label}\"", "shape=oval"]
|
||||
elif n.shape == "rounded":
|
||||
attrs.append("style=rounded")
|
||||
lines.append(f" \"{n.node_id}\" [{', '.join(attrs)}]; ")
|
||||
|
||||
lines.append("")
|
||||
for e in edges:
|
||||
safe_label = e.label.replace('"', "\\\"")
|
||||
if safe_label:
|
||||
lines.append(f" \"{e.src}\" -> \"{e.dst}\" [label=\"{safe_label}\"]; ")
|
||||
else:
|
||||
lines.append(f" \"{e.src}\" -> \"{e.dst}\"; ")
|
||||
|
||||
if {"S13", "S06"}.issubset(node_ids):
|
||||
lines.append(
|
||||
' "S13" -> "S06" [style=dashed, label="副作用: E16识别需创建后,对侧spawn S06"]; '
|
||||
)
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_mermaid(cfg: Mapping[str, Any]) -> str:
|
||||
# Mermaid flowchart is more forgiving than stateDiagram-v2 when labels are long.
|
||||
# Subgraphs are always enabled to reflect the five-stage mental model.
|
||||
nodes = _iter_state_nodes(cfg)
|
||||
edges = _collect_state_edges(cfg)
|
||||
node_ids = {n.node_id for n in nodes}
|
||||
|
||||
def _group(node_id: str) -> str:
|
||||
if node_id in {"S16", "S15", "S17", "S00"}:
|
||||
return "BOOT"
|
||||
if node_id in {"S01", "S02", "S03", "S04", "S05"}:
|
||||
return "BIND"
|
||||
if node_id in {"S06", "S10C", "S11C", "S12C"}:
|
||||
return "CREATE"
|
||||
if node_id in {"S07", "S08", "S10U", "S11U", "S12U", "S14"}:
|
||||
return "UPDATE"
|
||||
if node_id in {"S09", "S10D", "S11D", "S12D"}:
|
||||
return "DELETE"
|
||||
return "BIND"
|
||||
|
||||
group_titles = {
|
||||
"BOOT": "Bootstrap / Reset",
|
||||
"BIND": "Binding",
|
||||
"CREATE": "Create",
|
||||
"UPDATE": "Update",
|
||||
"DELETE": "Delete",
|
||||
}
|
||||
|
||||
grouped_nodes: Dict[str, List[Node]] = {k: [] for k in group_titles.keys()}
|
||||
for n in nodes:
|
||||
grouped_nodes[_group(n.node_id)].append(n)
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append("flowchart LR")
|
||||
|
||||
for group_id in ["BOOT", "BIND", "CREATE", "UPDATE", "DELETE"]:
|
||||
lines.append("")
|
||||
lines.append(f" subgraph {group_id}[\"{group_titles[group_id]}\"]")
|
||||
for n in sorted(grouped_nodes[group_id], key=lambda x: x.node_id):
|
||||
safe = n.label.replace("\\n", "<br/>").replace("\n", "<br/>").replace("\"", "'")
|
||||
if n.kind == "pseudo" or n.shape == "pseudo":
|
||||
lines.append(f" {n.node_id}([\"{safe}\"]) ")
|
||||
elif n.shape == "rounded":
|
||||
lines.append(f" {n.node_id}(\"{safe}\")")
|
||||
else:
|
||||
lines.append(f" {n.node_id}[\"{safe}\"]")
|
||||
lines.append(" end")
|
||||
|
||||
lines.append("")
|
||||
for e in edges:
|
||||
safe_label = e.label.replace("\"", "'")
|
||||
if safe_label:
|
||||
lines.append(f" {e.src} -- \"{safe_label}\" --> {e.dst}")
|
||||
else:
|
||||
lines.append(f" {e.src} --> {e.dst}")
|
||||
|
||||
if {"S13", "S06"}.issubset(node_ids):
|
||||
lines.append(' S13 -. "副作用: E16识别需创建后,对侧spawn S06" .-> S06')
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Render state machine graph from YAML")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=str(Path(__file__).resolve().parents[1] / "sync_state_machine" / "config" / "node_state_machine.yaml"),
|
||||
help="Path to YAML config",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["dot", "mermaid"],
|
||||
default="mermaid",
|
||||
help="Output format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default=str(Path(__file__).resolve().parents[1] / "docs" / "state_machine.mmd"),
|
||||
help="Output file path, or '-' for stdout",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
cfg = _load_yaml(Path(args.config))
|
||||
if args.format == "dot":
|
||||
out = render_dot(cfg)
|
||||
else:
|
||||
out = render_mermaid(cfg)
|
||||
|
||||
if args.out == "-":
|
||||
print(out)
|
||||
else:
|
||||
Path(args.out).write_text(out, encoding="utf-8")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,524 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# Make sure the repository root is importable so we can import
|
||||
# `sync_state_machine.common.types` when running as a script.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Problem:
|
||||
level: str # ERROR | WARN
|
||||
message: str
|
||||
path: str
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
except Exception as exc:
|
||||
raise ValidationError(f"Failed to read YAML: {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(f"Top-level YAML must be a mapping/dict: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def _as_list(x: Any) -> List[Any]:
|
||||
if x is None:
|
||||
return []
|
||||
if isinstance(x, list):
|
||||
return x
|
||||
return [x]
|
||||
|
||||
|
||||
def _require_keys(obj: Mapping[str, Any], keys: Sequence[str], base_path: str, problems: List[Problem]) -> None:
|
||||
for k in keys:
|
||||
if k not in obj:
|
||||
problems.append(Problem("ERROR", f"Missing required key '{k}'", f"{base_path}.{k}"))
|
||||
|
||||
|
||||
def _import_enums() -> Tuple[Set[str], Set[str], Set[str]]:
|
||||
# Only validate enum NAMES; values (lowercase strings) are implementation details.
|
||||
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
|
||||
|
||||
binding = {e.name for e in BindingStatus}
|
||||
action = {e.name for e in SyncAction}
|
||||
status = {e.name for e in SyncStatus}
|
||||
return binding, action, status
|
||||
|
||||
|
||||
def _validate_state_def(
|
||||
state_id: str,
|
||||
state_def: Mapping[str, Any],
|
||||
binding_names: Set[str],
|
||||
action_names: Set[str],
|
||||
status_names: Set[str],
|
||||
problems: List[Problem],
|
||||
) -> None:
|
||||
base = f"states.{state_id}"
|
||||
_require_keys(state_def, ["binding_status", "action", "status", "desc"], base, problems)
|
||||
|
||||
def _validate_enum_or_list(
|
||||
value: Any,
|
||||
*,
|
||||
allowed: Set[str],
|
||||
field_path: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
if isinstance(value, str):
|
||||
if value != "*" and value not in allowed:
|
||||
problems.append(Problem("ERROR", f"Unknown {label} name: {value}", field_path))
|
||||
return
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
problems.append(Problem("ERROR", f"{label} list must be non-empty", field_path))
|
||||
return
|
||||
for i, item in enumerate(value):
|
||||
if not isinstance(item, str):
|
||||
problems.append(Problem("ERROR", f"{label} list items must be strings", f"{field_path}[{i}]"))
|
||||
continue
|
||||
if item != "*" and item not in allowed:
|
||||
problems.append(Problem("ERROR", f"Unknown {label} name: {item}", f"{field_path}[{i}]"))
|
||||
return
|
||||
problems.append(Problem("ERROR", f"{label} must be a string, '*' or a list of strings", field_path))
|
||||
|
||||
_validate_enum_or_list(
|
||||
state_def.get("binding_status"),
|
||||
allowed=binding_names,
|
||||
field_path=f"{base}.binding_status",
|
||||
label="BindingStatus",
|
||||
)
|
||||
_validate_enum_or_list(
|
||||
state_def.get("action"),
|
||||
allowed=action_names,
|
||||
field_path=f"{base}.action",
|
||||
label="SyncAction",
|
||||
)
|
||||
_validate_enum_or_list(
|
||||
state_def.get("status"),
|
||||
allowed=status_names,
|
||||
field_path=f"{base}.status",
|
||||
label="SyncStatus",
|
||||
)
|
||||
|
||||
# Optional: whether the node has data_id at this state.
|
||||
# Used for signature uniqueness checks; kept optional to avoid forcing all configs to specify it.
|
||||
data_id_exist = state_def.get("data_id_exist", None)
|
||||
if data_id_exist is not None:
|
||||
field_path = f"{base}.data_id_exist"
|
||||
if data_id_exist == "*":
|
||||
pass
|
||||
elif isinstance(data_id_exist, bool):
|
||||
pass
|
||||
elif isinstance(data_id_exist, list):
|
||||
if not data_id_exist:
|
||||
problems.append(Problem("ERROR", "data_id_exist list must be non-empty", field_path))
|
||||
else:
|
||||
for i, item in enumerate(data_id_exist):
|
||||
if item == "*":
|
||||
continue
|
||||
if not isinstance(item, bool):
|
||||
problems.append(
|
||||
Problem(
|
||||
"ERROR",
|
||||
"data_id_exist list items must be bool or '*'",
|
||||
f"{field_path}[{i}]",
|
||||
)
|
||||
)
|
||||
else:
|
||||
problems.append(Problem("ERROR", "data_id_exist must be bool, '*', or a list of them", field_path))
|
||||
|
||||
|
||||
def _expand_enum_values(value: Any, *, all_values: Set[str]) -> Set[str]:
|
||||
if isinstance(value, str):
|
||||
return set(all_values) if value == "*" else {value}
|
||||
if isinstance(value, list):
|
||||
out: Set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
if item == "*":
|
||||
out |= set(all_values)
|
||||
else:
|
||||
out.add(item)
|
||||
return out
|
||||
return set()
|
||||
|
||||
|
||||
def _expand_data_id_exist(value: Any) -> Set[bool]:
|
||||
# Missing is treated as wildcard for uniqueness check (strict).
|
||||
if value is None or value == "*":
|
||||
return {True, False}
|
||||
if isinstance(value, bool):
|
||||
return {value}
|
||||
if isinstance(value, list):
|
||||
out: Set[bool] = set()
|
||||
for item in value:
|
||||
if item == "*":
|
||||
out |= {True, False}
|
||||
elif isinstance(item, bool):
|
||||
out.add(item)
|
||||
return out if out else {True, False}
|
||||
return {True, False}
|
||||
|
||||
|
||||
def _validate_state_signature_uniqueness(
|
||||
states: Mapping[str, Any],
|
||||
*,
|
||||
binding_names: Set[str],
|
||||
action_names: Set[str],
|
||||
status_names: Set[str],
|
||||
problems: List[Problem],
|
||||
) -> None:
|
||||
"""Ensure no two states can share the same (binding_status, action, status, data_id_exist).
|
||||
|
||||
Semantics:
|
||||
- '*' expands to all enum values.
|
||||
- list expands to the set.
|
||||
- data_id_exist missing expands to {True, False} (strict), so uniqueness failures surface.
|
||||
"""
|
||||
|
||||
sig_to_states: Dict[Tuple[str, str, str, bool], List[str]] = {}
|
||||
|
||||
for sid, sdef in states.items():
|
||||
if not isinstance(sdef, dict):
|
||||
continue
|
||||
|
||||
binding_set = _expand_enum_values(sdef.get("binding_status"), all_values=binding_names)
|
||||
action_set = _expand_enum_values(sdef.get("action"), all_values=action_names)
|
||||
status_set = _expand_enum_values(sdef.get("status"), all_values=status_names)
|
||||
data_id_set = _expand_data_id_exist(sdef.get("data_id_exist"))
|
||||
|
||||
# If any of the sets are empty due to prior validation errors, skip to avoid noise.
|
||||
if not binding_set or not action_set or not status_set:
|
||||
continue
|
||||
|
||||
for b in binding_set:
|
||||
for a in action_set:
|
||||
for st in status_set:
|
||||
for de in data_id_set:
|
||||
sig_to_states.setdefault((b, a, st, de), []).append(str(sid))
|
||||
|
||||
collisions = [(sig, sids) for sig, sids in sig_to_states.items() if len(set(sids)) > 1]
|
||||
collisions.sort(key=lambda x: (-len(set(x[1])), x[0]))
|
||||
|
||||
for sig, sids in collisions:
|
||||
uniq = sorted(set(sids))
|
||||
problems.append(
|
||||
Problem(
|
||||
"ERROR",
|
||||
f"State signature overlaps for (binding_status, action, status, data_id_exist)={sig}: {', '.join(uniq)}",
|
||||
"states",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _collect_state_ids(cfg: Mapping[str, Any], problems: List[Problem]) -> Set[str]:
|
||||
states = cfg.get("states")
|
||||
if not isinstance(states, dict):
|
||||
problems.append(Problem("ERROR", "'states' must be a mapping", "states"))
|
||||
return set()
|
||||
return set(states.keys())
|
||||
|
||||
|
||||
def _validate_context(cfg: Mapping[str, Any], problems: List[Problem]) -> Set[str]:
|
||||
ctx = cfg.get("context")
|
||||
if not isinstance(ctx, dict):
|
||||
problems.append(Problem("ERROR", "'context' must be a mapping", "context"))
|
||||
return set()
|
||||
|
||||
ctx_keys: Set[str] = set()
|
||||
for k, v in ctx.items():
|
||||
ctx_keys.add(k)
|
||||
if not isinstance(v, dict):
|
||||
problems.append(Problem("ERROR", "context entry must be a mapping", f"context.{k}"))
|
||||
continue
|
||||
if "type" not in v:
|
||||
problems.append(Problem("ERROR", "context entry missing 'type'", f"context.{k}.type"))
|
||||
if "desc" not in v:
|
||||
problems.append(Problem("WARN", "context entry missing 'desc'", f"context.{k}.desc"))
|
||||
if v.get("type") == "enum":
|
||||
vals = v.get("values")
|
||||
if not isinstance(vals, list) or not vals:
|
||||
problems.append(Problem("ERROR", "enum context must have non-empty 'values'", f"context.{k}.values"))
|
||||
return ctx_keys
|
||||
|
||||
|
||||
def _validate_stage(cfg: Mapping[str, Any], stage_id: str, stage_def: Mapping[str, Any], state_ids: Set[str], problems: List[Problem]) -> None:
|
||||
base = f"stages.{stage_id}"
|
||||
_require_keys(stage_def, ["desc", "entry_states", "exit_states"], base, problems)
|
||||
|
||||
for key in ("entry_states", "exit_states"):
|
||||
vals = stage_def.get(key)
|
||||
if not isinstance(vals, list) or not vals:
|
||||
problems.append(Problem("ERROR", f"{key} must be a non-empty list", f"{base}.{key}"))
|
||||
continue
|
||||
for s in vals:
|
||||
if s == "*":
|
||||
continue
|
||||
if s not in state_ids and s not in (cfg.get("pseudo_states") or {}):
|
||||
problems.append(Problem("ERROR", f"Unknown state id '{s}'", f"{base}.{key}"))
|
||||
|
||||
|
||||
def _validate_stages(cfg: Mapping[str, Any], state_ids: Set[str], problems: List[Problem]) -> Set[str]:
|
||||
stages = cfg.get("stages")
|
||||
if not isinstance(stages, dict):
|
||||
problems.append(Problem("ERROR", "'stages' must be a mapping", "stages"))
|
||||
return set()
|
||||
|
||||
stage_ids: Set[str] = set()
|
||||
for sid, sdef in stages.items():
|
||||
stage_ids.add(sid)
|
||||
if not isinstance(sdef, dict):
|
||||
problems.append(Problem("ERROR", "stage definition must be a mapping", f"stages.{sid}"))
|
||||
continue
|
||||
_validate_stage(cfg, sid, sdef, state_ids, problems)
|
||||
return stage_ids
|
||||
|
||||
|
||||
def _guard_keys(guard: Mapping[str, Any]) -> Set[str]:
|
||||
keys: Set[str] = set()
|
||||
for k, v in guard.items():
|
||||
if isinstance(v, dict):
|
||||
# Nested structures are not allowed for now.
|
||||
continue
|
||||
keys.add(k)
|
||||
return keys
|
||||
|
||||
|
||||
def _validate_transition(
|
||||
tid: str,
|
||||
tdef: Mapping[str, Any],
|
||||
stage_ids: Set[str],
|
||||
state_ids: Set[str],
|
||||
pseudo_state_ids: Set[str],
|
||||
context_keys: Set[str],
|
||||
problems: List[Problem],
|
||||
) -> None:
|
||||
base = f"transitions.{tid}"
|
||||
_require_keys(tdef, ["stage", "desc", "from", "outcomes"], base, problems)
|
||||
|
||||
stage = tdef.get("stage")
|
||||
if isinstance(stage, str) and stage not in stage_ids:
|
||||
problems.append(Problem("ERROR", f"Unknown stage '{stage}'", f"{base}.stage"))
|
||||
|
||||
frm = tdef.get("from")
|
||||
if not isinstance(frm, list) or not frm:
|
||||
problems.append(Problem("ERROR", "'from' must be a non-empty list", f"{base}.from"))
|
||||
else:
|
||||
for s in frm:
|
||||
if s == "*":
|
||||
continue
|
||||
if s not in state_ids and s not in pseudo_state_ids:
|
||||
problems.append(Problem("ERROR", f"Unknown from-state '{s}'", f"{base}.from"))
|
||||
|
||||
outcomes = tdef.get("outcomes")
|
||||
if not isinstance(outcomes, list) or not outcomes:
|
||||
problems.append(Problem("ERROR", "'outcomes' must be a non-empty list", f"{base}.outcomes"))
|
||||
return
|
||||
|
||||
# Basic overlap check for outcome guards.
|
||||
normalized_guards: List[Tuple[int, Dict[str, Any]]] = []
|
||||
|
||||
for i, o in enumerate(outcomes):
|
||||
opath = f"{base}.outcomes[{i}]"
|
||||
if not isinstance(o, dict):
|
||||
problems.append(Problem("ERROR", "outcome must be a mapping", opath))
|
||||
continue
|
||||
if "when" not in o:
|
||||
problems.append(Problem("ERROR", "outcome missing 'when'", f"{opath}.when"))
|
||||
continue
|
||||
when = o.get("when")
|
||||
if not isinstance(when, dict):
|
||||
problems.append(Problem("ERROR", "'when' must be a mapping", f"{opath}.when"))
|
||||
continue
|
||||
|
||||
# Guard variable key validation
|
||||
for k in _guard_keys(when):
|
||||
if k not in context_keys and k not in ("self.status",):
|
||||
problems.append(Problem("ERROR", f"Unknown context key '{k}'", f"{opath}.when.{k}"))
|
||||
|
||||
has_to = "to" in o
|
||||
has_to_map = "to_map" in o
|
||||
if not (has_to or has_to_map):
|
||||
problems.append(Problem("ERROR", "outcome must have 'to' or 'to_map'", opath))
|
||||
continue
|
||||
|
||||
if has_to:
|
||||
to = o.get("to")
|
||||
if not isinstance(to, str):
|
||||
problems.append(Problem("ERROR", "'to' must be a state id string", f"{opath}.to"))
|
||||
else:
|
||||
if to not in state_ids and to not in pseudo_state_ids:
|
||||
problems.append(Problem("ERROR", f"Unknown to-state '{to}'", f"{opath}.to"))
|
||||
|
||||
if has_to_map:
|
||||
tm = o.get("to_map")
|
||||
if not isinstance(tm, dict):
|
||||
problems.append(Problem("ERROR", "'to_map' must be a mapping", f"{opath}.to_map"))
|
||||
else:
|
||||
if tm.get("key") != "self.status":
|
||||
problems.append(Problem("WARN", "Only to_map.key=self.status is supported by validator", f"{opath}.to_map.key"))
|
||||
mp = tm.get("map")
|
||||
if not isinstance(mp, dict) or not mp:
|
||||
problems.append(Problem("ERROR", "to_map.map must be a non-empty mapping", f"{opath}.to_map.map"))
|
||||
else:
|
||||
for k, v in mp.items():
|
||||
if not isinstance(k, str):
|
||||
problems.append(Problem("ERROR", "to_map.map keys must be strings", f"{opath}.to_map.map"))
|
||||
if not isinstance(v, str) or v not in state_ids:
|
||||
problems.append(Problem("ERROR", f"Unknown to_map target '{v}'", f"{opath}.to_map.map.{k}"))
|
||||
|
||||
emit = o.get("emit")
|
||||
if emit is not None:
|
||||
if not isinstance(emit, dict):
|
||||
problems.append(Problem("ERROR", "emit must be a mapping", f"{opath}.emit"))
|
||||
else:
|
||||
if "spawn_target_node_state" in emit:
|
||||
spawn = emit.get("spawn_target_node_state")
|
||||
if spawn not in state_ids:
|
||||
problems.append(Problem("ERROR", f"Unknown spawn_target_node_state '{spawn}'", f"{opath}.emit.spawn_target_node_state"))
|
||||
|
||||
normalized_guards.append((i, dict(when)))
|
||||
|
||||
# Overlap check: if two guards are compatible (no contradictory assignments), they overlap.
|
||||
for i in range(len(normalized_guards)):
|
||||
idx_a, ga = normalized_guards[i]
|
||||
for j in range(i + 1, len(normalized_guards)):
|
||||
idx_b, gb = normalized_guards[j]
|
||||
if _guards_overlap(ga, gb):
|
||||
problems.append(
|
||||
Problem(
|
||||
"WARN",
|
||||
f"Outcome guards may overlap (order-dependent): outcomes[{idx_a}] vs outcomes[{idx_b}]",
|
||||
f"{base}.outcomes",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _guards_overlap(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool:
|
||||
for k, va in a.items():
|
||||
if k not in b:
|
||||
continue
|
||||
vb = b[k]
|
||||
if va != vb:
|
||||
return False
|
||||
for k, vb in b.items():
|
||||
if k not in a:
|
||||
continue
|
||||
va = a[k]
|
||||
if va != vb:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_config(cfg_path: Path) -> Tuple[List[Problem], Dict[str, Any]]:
|
||||
cfg = _load_yaml(cfg_path)
|
||||
|
||||
problems: List[Problem] = []
|
||||
|
||||
binding_names, action_names, status_names = _import_enums()
|
||||
|
||||
# meta
|
||||
meta = cfg.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
problems.append(Problem("ERROR", "'meta' must be a mapping", "meta"))
|
||||
|
||||
# context
|
||||
context_keys = _validate_context(cfg, problems)
|
||||
|
||||
# states
|
||||
state_ids = _collect_state_ids(cfg, problems)
|
||||
states = cfg.get("states") if isinstance(cfg.get("states"), dict) else {}
|
||||
for sid, sdef in states.items():
|
||||
if not isinstance(sdef, dict):
|
||||
problems.append(Problem("ERROR", "state definition must be a mapping", f"states.{sid}"))
|
||||
continue
|
||||
_validate_state_def(sid, sdef, binding_names, action_names, status_names, problems)
|
||||
|
||||
# Ensure states are uniquely identifiable by a strict field signature.
|
||||
_validate_state_signature_uniqueness(
|
||||
states,
|
||||
binding_names=binding_names,
|
||||
action_names=action_names,
|
||||
status_names=status_names,
|
||||
problems=problems,
|
||||
)
|
||||
|
||||
pseudo_states = cfg.get("pseudo_states")
|
||||
pseudo_state_ids: Set[str] = set()
|
||||
if pseudo_states is not None:
|
||||
if not isinstance(pseudo_states, dict):
|
||||
problems.append(Problem("ERROR", "'pseudo_states' must be a mapping", "pseudo_states"))
|
||||
else:
|
||||
pseudo_state_ids = set(pseudo_states.keys())
|
||||
|
||||
# stages
|
||||
stage_ids = _validate_stages(cfg, state_ids, problems)
|
||||
|
||||
# transitions
|
||||
transitions = cfg.get("transitions")
|
||||
if not isinstance(transitions, dict):
|
||||
problems.append(Problem("ERROR", "'transitions' must be a mapping", "transitions"))
|
||||
else:
|
||||
for tid, tdef in transitions.items():
|
||||
if not isinstance(tdef, dict):
|
||||
problems.append(Problem("ERROR", "transition definition must be a mapping", f"transitions.{tid}"))
|
||||
continue
|
||||
_validate_transition(tid, tdef, stage_ids, state_ids, pseudo_state_ids, context_keys, problems)
|
||||
|
||||
# invariants: basic structural checks only (semantics validated by runtime engine)
|
||||
inv = cfg.get("invariants")
|
||||
if inv is not None and not isinstance(inv, dict):
|
||||
problems.append(Problem("ERROR", "'invariants' must be a mapping", "invariants"))
|
||||
|
||||
return problems, cfg
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate state machine YAML config")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=str(Path(__file__).resolve().parents[1] / "sync_state_machine" / "config" / "node_state_machine.yaml"),
|
||||
help="Path to YAML config",
|
||||
)
|
||||
args = parser.parse_args(list(argv) if argv is not None else None)
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
problems, _ = validate_config(cfg_path)
|
||||
|
||||
errors = [p for p in problems if p.level == "ERROR"]
|
||||
warns = [p for p in problems if p.level == "WARN"]
|
||||
|
||||
for p in problems:
|
||||
print(f"[{p.level}] {p.path}: {p.message}")
|
||||
|
||||
print("\nSummary:")
|
||||
print(f" errors: {len(errors)}")
|
||||
print(f" warns : {len(warns)}")
|
||||
|
||||
if errors:
|
||||
return 2
|
||||
if warns:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user