Fix golden audit issues and tighten analysis/export pipeline.
Resolve property placeholders before call-chain linking, skip phantom routing states and lifecycle triggers from transition matching, dedupe exported states, and add golden JSON audit regression plus HTML lifecycle endpoint display. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deep audit of golden JSON analysis exports."""
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
GOLDEN = Path(__file__).resolve().parent.parent / "src/test/resources/golden"
|
||||
PH = re.compile(r"\$\{")
|
||||
|
||||
|
||||
def fn_form(identifier):
|
||||
if not identifier:
|
||||
return identifier
|
||||
last = identifier.rfind(".")
|
||||
if last <= 0:
|
||||
return identifier
|
||||
prev = identifier.rfind(".", 0, last)
|
||||
return identifier[prev + 1 :] if prev > 0 else identifier
|
||||
|
||||
|
||||
def sid(st):
|
||||
if isinstance(st, dict):
|
||||
return st.get("fullIdentifier") or st.get("rawName")
|
||||
return st
|
||||
|
||||
|
||||
def eid(ev):
|
||||
if isinstance(ev, dict):
|
||||
return ev.get("fullIdentifier") or ev.get("rawName")
|
||||
return ev
|
||||
|
||||
|
||||
def audit_file(path, data):
|
||||
name = data.get("name", path.stem)
|
||||
issues = []
|
||||
st_fqn = data.get("stateTypeFqn")
|
||||
ev_fqn = data.get("eventTypeFqn")
|
||||
transitions = data.get("transitions") or []
|
||||
starts = list(data.get("startStates") or [])
|
||||
ends = list(data.get("endStates") or [])
|
||||
states = data.get("states") or []
|
||||
meta = data.get("metadata") or {}
|
||||
|
||||
is_pkg_enum = st_fqn and "." in st_fqn and st_fqn not in ("String", "java.lang.String")
|
||||
is_string = st_fqn in (None, "String", "java.lang.String") or (
|
||||
not st_fqn and not ev_fqn
|
||||
)
|
||||
|
||||
if PH.search(json.dumps(data)):
|
||||
issues.append("BUG: unresolved ${placeholder} in export")
|
||||
|
||||
# Duplicate fullIdentifiers in states[]
|
||||
by_full = defaultdict(list)
|
||||
for s in states:
|
||||
by_full[sid(s)].append(s.get("rawName"))
|
||||
for fid, raws in by_full.items():
|
||||
if fid and len(raws) > 1 and len(set(raws)) > 1:
|
||||
issues.append(f"NOISE: duplicate state entry {fid!r} with rawNames {raws}")
|
||||
|
||||
# Collect transition state/event ids
|
||||
t_events, t_sources, t_targets = set(), set(), set()
|
||||
for t in transitions:
|
||||
if eid(t.get("event")):
|
||||
t_events.add(eid(t.get("event")))
|
||||
for s in t.get("sourceStates") or []:
|
||||
t_sources.add(sid(s))
|
||||
for s in t.get("targetStates") or []:
|
||||
t_targets.add(sid(s))
|
||||
|
||||
graph = t_sources | t_targets
|
||||
|
||||
# startStates canonical consistency
|
||||
for ss in starts:
|
||||
if is_string and ss and not ss.startswith("String."):
|
||||
if any(x.startswith("String.") for x in graph):
|
||||
issues.append(f"BUG: startStates {ss!r} bare but transitions use String.* form")
|
||||
if ss not in graph and fn_form(ss) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"WARN: startStates {ss!r} not in transition graph")
|
||||
|
||||
# endStates
|
||||
for es in ends:
|
||||
if es not in graph and fn_form(es) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"WARN: endStates {es!r} not in transition graph")
|
||||
|
||||
# Enum canonical fullIdentifiers
|
||||
if is_pkg_enum:
|
||||
for i, t in enumerate(transitions):
|
||||
for side in ("sourceStates", "targetStates"):
|
||||
for s in t.get(side) or []:
|
||||
fid = sid(s)
|
||||
if fid and not fid.startswith(st_fqn + ".") and not fid.startswith('"'):
|
||||
if "." in fid and not fid.startswith("<"):
|
||||
issues.append(f"BUG: transitions[{i}].{side} {fid!r} not under {st_fqn}")
|
||||
ev = eid(t.get("event"))
|
||||
if ev and ev_fqn and "." in ev_fqn and not ev.startswith("<") and not ev.startswith(ev_fqn + "."):
|
||||
if ev not in ("event",) and "." in ev and not ev.startswith(ev_fqn):
|
||||
issues.append(f"BUG: transitions[{i}].event {ev!r} not under {ev_fqn}")
|
||||
for i, tr in enumerate(meta.get("triggers") or []):
|
||||
for field in ("event", "sourceState"):
|
||||
val = tr.get(field)
|
||||
if not val or val in ("event", "payload", "customMessage", "eventProvider"):
|
||||
continue
|
||||
type_fqn = ev_fqn if field == "event" else st_fqn
|
||||
if type_fqn and "." in val and not val.startswith("<") and not val.startswith(type_fqn + "."):
|
||||
if field == "sourceState" and val not in graph and fn_form(val) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"BUG: triggers[{i}].sourceState {val!r} not a machine state")
|
||||
elif field == "event" and type_fqn and val.isupper() is False and "." not in val and not val.startswith("<"):
|
||||
if val not in ("event", "payload", "customMessage", "eventProvider", "true", "false"):
|
||||
issues.append(f"INFO: triggers[{i}].event dynamic {val!r}")
|
||||
|
||||
# matchedTransitions
|
||||
for i, chain in enumerate(meta.get("callChains") or []):
|
||||
for j, mt in enumerate(chain.get("matchedTransitions") or []):
|
||||
me, ms, mtgt = mt.get("event"), mt.get("sourceState"), mt.get("targetState")
|
||||
ok = False
|
||||
for t in transitions:
|
||||
if fn_form(eid(t.get("event"))) != fn_form(me):
|
||||
continue
|
||||
srcs = [sid(s) for s in t.get("sourceStates") or []]
|
||||
tgts = [sid(s) for s in t.get("targetStates") or []]
|
||||
if (not ms or any(fn_form(x) == fn_form(ms) for x in srcs)) and (
|
||||
not mtgt or any(fn_form(x) == fn_form(mtgt) for x in tgts)
|
||||
):
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
issues.append(
|
||||
f"BUG: callChains[{i}].matchedTransitions[{j}] ({ms!r},{me!r},{mtgt!r}) no transition"
|
||||
)
|
||||
|
||||
# String machine: mixed NEW vs String.NEW
|
||||
if is_string or st_fqn == "String":
|
||||
all_ids = set(graph) | set(starts) | set(ends) | {sid(s) for s in states}
|
||||
for x in list(all_ids):
|
||||
if not x:
|
||||
continue
|
||||
bare = x.strip('"')
|
||||
if bare and "." not in bare and f"String.{bare}" in all_ids:
|
||||
issues.append(f"BUG: mixed bare {x!r} and String.{bare}")
|
||||
|
||||
# stateTypeFqn missing on enum machine
|
||||
if is_pkg_enum and not st_fqn:
|
||||
issues.append("BUG: enum machine missing stateTypeFqn")
|
||||
|
||||
return name, issues
|
||||
|
||||
|
||||
def main():
|
||||
total = 0
|
||||
for path in sorted(GOLDEN.rglob("*.json")):
|
||||
data = json.loads(path.read_text())
|
||||
machine, issues = audit_file(path, data)
|
||||
rel = path.relative_to(GOLDEN)
|
||||
bugs = [i for i in issues if i.startswith("BUG")]
|
||||
warns = [i for i in issues if i.startswith("WARN")]
|
||||
noise = [i for i in issues if i.startswith("NOISE")]
|
||||
info = [i for i in issues if i.startswith("INFO")]
|
||||
if bugs or warns:
|
||||
print(f"\n{'='*70}\n{rel}\n machine: {machine}")
|
||||
for i in bugs + warns + noise + info:
|
||||
print(f" [{i.split(':')[0]}] {i.split(': ', 1)[-1]}")
|
||||
total += len(bugs) + len(warns)
|
||||
print(f"\nTotal BUG+WARN: {total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user