| |
| """ |
| validate_dsl.py |
| |
| The Phase 2 accept/reject gate for Diagram DSL. |
| |
| Two layers, both enforced here (zero pip dependencies — pure stdlib): |
| |
| 1. STRUCTURAL — a small JSON-Schema validator that reads `dsl_schema.json` and checks |
| types, enums, required keys, additionalProperties, lengths, patterns, and the |
| diagram-specific allOf/if-then conditionals. The schema file stays the single source |
| of truth; this validator just interprets it. |
| |
| 2. SEMANTIC — the rules JSON Schema cannot express (DSL_SPEC.md section 2): unique ids, |
| edge endpoints resolve, no isolated nodes, flowchart-has-one-start, pipeline-is-acyclic, |
| mind_map-is-a-tree, etc. These are the rules that actually keep the dataset clean. |
| |
| A DSL is ACCEPTED iff it produces zero errors. Warnings never reject — they flag rows worth |
| a human glance (e.g. very dense graphs, heavy color overrides). |
| |
| Usage: |
| # validate a single DSL file (or '-' for stdin) |
| python3 validate_dsl.py diagram.dsl.json |
| cat diagram.dsl.json | python3 validate_dsl.py - |
| |
| # validate every assistant DSL in a training file (the dataset gate) |
| python3 validate_dsl.py --jsonl train.jsonl |
| python3 validate_dsl.py --jsonl train.jsonl --max-show 20 --warnings |
| |
| Exit code is 0 when everything passed, 1 otherwise — usable in CI / generation loops. |
| |
| As a library: |
| from validate_dsl import validate_dsl, validate_dsl_text, Result |
| res = validate_dsl(dsl_dict) |
| if res.ok: ... |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import sys |
|
|
| SCHEMA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dsl_schema.json") |
|
|
| |
| CONNECTED_TYPES = {"system_architecture", "flowchart", "data_pipeline", "mind_map"} |
|
|
|
|
| |
| |
| |
|
|
| class Result: |
| """Collects validation findings. `ok` is True iff there are no errors.""" |
|
|
| def __init__(self): |
| self.errors = [] |
| self.warnings = [] |
|
|
| def error(self, code, message): |
| self.errors.append((code, message)) |
|
|
| def warn(self, code, message): |
| self.warnings.append((code, message)) |
|
|
| @property |
| def ok(self): |
| return not self.errors |
|
|
| def extend(self, other): |
| self.errors.extend(other.errors) |
| self.warnings.extend(other.warnings) |
|
|
| def __str__(self): |
| lines = [] |
| for code, msg in self.errors: |
| lines.append(f" ERROR [{code}] {msg}") |
| for code, msg in self.warnings: |
| lines.append(f" WARN [{code}] {msg}") |
| if not lines: |
| lines.append(" OK") |
| return "\n".join(lines) |
|
|
|
|
| |
| |
| |
|
|
| _JSON_TYPES = { |
| "object": dict, |
| "array": list, |
| "string": str, |
| "number": (int, float), |
| "integer": int, |
| "boolean": bool, |
| "null": type(None), |
| } |
|
|
|
|
| def _type_matches(value, type_name): |
| |
| if type_name in ("number", "integer") and isinstance(value, bool): |
| return False |
| if type_name == "boolean": |
| return isinstance(value, bool) |
| return isinstance(value, _JSON_TYPES[type_name]) |
|
|
|
|
| class SchemaValidator: |
| """Interprets the subset of JSON Schema 2020-12 that dsl_schema.json actually uses: |
| $ref/$defs, type, enum, const, required, properties, additionalProperties, items, |
| minItems/maxItems, minLength/maxLength, pattern, allOf, and if/then/else.""" |
|
|
| def __init__(self, schema): |
| self.root = schema |
|
|
| def validate(self, instance): |
| errors = [] |
| self._check(instance, self.root, "$", errors) |
| return errors |
|
|
| |
|
|
| def _resolve(self, schema): |
| |
| |
| seen = 0 |
| while isinstance(schema, dict) and "$ref" in schema: |
| ref = schema["$ref"] |
| parts = [p for p in ref.lstrip("#/").split("/") if p] |
| target = self.root |
| for p in parts: |
| target = target[p] |
| schema = target |
| seen += 1 |
| if seen > 50: |
| break |
| return schema |
|
|
| def _matches(self, instance, schema): |
| """True iff `instance` validates against `schema` (used for if/then).""" |
| tmp = [] |
| self._check(instance, schema, "$", tmp) |
| return not tmp |
|
|
| def _check(self, instance, schema, path, errors): |
| schema = self._resolve(schema) |
| if not isinstance(schema, dict): |
| return |
|
|
| |
| for sub in schema.get("allOf", []): |
| self._check(instance, sub, path, errors) |
|
|
| |
| if "if" in schema: |
| if self._matches(instance, schema["if"]): |
| if "then" in schema: |
| self._check(instance, schema["then"], path, errors) |
| elif "else" in schema: |
| self._check(instance, schema["else"], path, errors) |
|
|
| |
| if "type" in schema: |
| t = schema["type"] |
| types = t if isinstance(t, list) else [t] |
| if not any(_type_matches(instance, tn) for tn in types): |
| errors.append(("schema", f"{path}: expected type {t}, got {_pytype(instance)}")) |
| return |
|
|
| |
| if "enum" in schema and instance not in schema["enum"]: |
| errors.append(("schema", f"{path}: {instance!r} is not one of {schema['enum']}")) |
| if "const" in schema and instance != schema["const"]: |
| errors.append(("schema", f"{path}: expected const {schema['const']!r}")) |
|
|
| if isinstance(instance, str): |
| self._check_string(instance, schema, path, errors) |
| elif isinstance(instance, list): |
| self._check_array(instance, schema, path, errors) |
| elif isinstance(instance, dict): |
| self._check_object(instance, schema, path, errors) |
|
|
| def _check_string(self, instance, schema, path, errors): |
| if "minLength" in schema and len(instance) < schema["minLength"]: |
| errors.append(("schema", f"{path}: shorter than minLength {schema['minLength']}")) |
| if "maxLength" in schema and len(instance) > schema["maxLength"]: |
| errors.append(("schema", f"{path}: longer than maxLength {schema['maxLength']}")) |
| if "pattern" in schema and not re.search(schema["pattern"], instance): |
| errors.append(("schema", f"{path}: {instance!r} does not match pattern {schema['pattern']}")) |
|
|
| def _check_array(self, instance, schema, path, errors): |
| if "minItems" in schema and len(instance) < schema["minItems"]: |
| errors.append(("schema", f"{path}: fewer than minItems {schema['minItems']}")) |
| if "maxItems" in schema and len(instance) > schema["maxItems"]: |
| errors.append(("schema", f"{path}: more than maxItems {schema['maxItems']}")) |
| items = schema.get("items") |
| if items is not None: |
| for i, el in enumerate(instance): |
| self._check(el, items, f"{path}[{i}]", errors) |
|
|
| def _check_object(self, instance, schema, path, errors): |
| props = schema.get("properties", {}) |
| for req in schema.get("required", []): |
| if req not in instance: |
| errors.append(("schema", f"{path}: missing required property '{req}'")) |
| additional = schema.get("additionalProperties", True) |
| for key, val in instance.items(): |
| if key in props: |
| self._check(val, props[key], f"{path}.{key}", errors) |
| elif additional is False: |
| errors.append(("schema", f"{path}: unexpected property '{key}' (additionalProperties=false)")) |
| elif isinstance(additional, dict): |
| self._check(val, additional, f"{path}.{key}", errors) |
|
|
|
|
| def _pytype(v): |
| if isinstance(v, bool): |
| return "boolean" |
| if isinstance(v, dict): |
| return "object" |
| if isinstance(v, list): |
| return "array" |
| if isinstance(v, str): |
| return "string" |
| if isinstance(v, (int, float)): |
| return "number" |
| if v is None: |
| return "null" |
| return type(v).__name__ |
|
|
|
|
| |
| |
| |
|
|
| def _semantic(dsl, res): |
| diagram = dsl["diagram"] |
| nodes = dsl["nodes"] |
| edges = dsl.get("edges", []) or [] |
| groups = dsl.get("groups", []) or [] |
|
|
| node_ids = [n["id"] for n in nodes] |
| id_set = set(node_ids) |
| by_id = {n["id"]: n for n in nodes} |
| group_ids = [g["id"] for g in groups] |
|
|
| _universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res) |
|
|
| |
| valid_edges = [e for e in edges if e["from"] in id_set and e["to"] in id_set] |
|
|
| dispatch = { |
| "flowchart": _flowchart, |
| "data_pipeline": _data_pipeline, |
| "sequence_diagram": _sequence, |
| "er_diagram": _er, |
| "mind_map": _mind_map, |
| "timeline": _timeline, |
| "mobile_wireframe": _wireframe, |
| } |
| fn = dispatch.get(diagram) |
| if fn: |
| fn(nodes, valid_edges, edges, by_id, id_set, groups, res) |
|
|
| _size_guards(nodes, edges, res) |
|
|
|
|
| def _roles(nodes, role): |
| return [n for n in nodes if n.get("role") == role] |
|
|
|
|
| def _universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res): |
| |
| for dup in _dups(node_ids): |
| res.error("U1", f"duplicate node id '{dup}'") |
| |
| for dup in _dups(group_ids): |
| res.error("U2", f"duplicate group id '{dup}'") |
| group_set = set(group_ids) |
|
|
| |
| for i, e in enumerate(edges): |
| if e["from"] not in id_set: |
| res.error("U3", f"edges[{i}].from '{e['from']}' is not a node id") |
| if e["to"] not in id_set: |
| res.error("U3", f"edges[{i}].to '{e['to']}' is not a node id") |
|
|
| |
| for n in nodes: |
| g = n.get("group") |
| if g is not None and g not in group_set: |
| res.error("U4", f"node '{n['id']}' references unknown group '{g}'") |
|
|
| |
| for i, e in enumerate(edges): |
| if e["from"] == e["to"]: |
| res.error("U5", f"edges[{i}] is a self-loop on '{e['from']}'") |
|
|
| |
| if diagram != "sequence_diagram": |
| seen = set() |
| for i, e in enumerate(edges): |
| key = (e["from"], e["to"], e.get("label", "")) |
| if key in seen: |
| res.error("U6", f"edges[{i}] duplicates edge {e['from']}->{e['to']} (label {e.get('label','')!r})") |
| seen.add(key) |
|
|
| |
| if diagram in CONNECTED_TYPES: |
| touched = set() |
| for e in edges: |
| touched.add(e["from"]) |
| touched.add(e["to"]) |
| for n in nodes: |
| if n["id"] not in touched: |
| res.error("U7", f"node '{n['id']}' is isolated (no edge) in a connected diagram") |
|
|
| |
| overrides = sum(1 for n in nodes if n.get("color")) |
| if nodes and overrides / len(nodes) > 0.30: |
| res.warn("U8", f"{overrides}/{len(nodes)} nodes override color (>30%) — styling should come from role") |
|
|
| |
| _label_hygiene("title", dsl.get("title", ""), res) |
| for n in nodes: |
| _label_hygiene(f"node '{n['id']}' label", n.get("label", ""), res) |
| for g in groups: |
| _label_hygiene(f"group '{g['id']}' label", g.get("label", ""), res) |
| for i, e in enumerate(edges): |
| if "label" in e: |
| _label_hygiene(f"edges[{i}] label", e["label"], res) |
|
|
|
|
| def _label_hygiene(where, text, res): |
| if "\n" in text or "\r" in text: |
| res.error("U9", f"{where} contains a newline (converter handles wrapping)") |
| elif text != text.strip(): |
| res.warn("U9", f"{where} has leading/trailing whitespace") |
|
|
|
|
| def _flowchart(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| starts = _roles(nodes, "start") |
| |
| if len(starts) != 1: |
| res.error("F1", f"flowchart needs exactly one 'start' node, found {len(starts)}") |
| |
| if not _roles(nodes, "end"): |
| res.error("F2", "flowchart needs at least one 'end' node") |
|
|
| out = _out_adj(edges) |
| |
| for n in nodes: |
| if n.get("role") == "decision": |
| outs = [e for e in edges if e["from"] == n["id"]] |
| if len(outs) < 2: |
| res.error("F3", f"decision node '{n['id']}' has {len(outs)} outgoing edges (need >= 2)") |
| for e in outs: |
| if not e.get("label", "").strip(): |
| res.error("F3", f"decision node '{n['id']}' has an unlabeled branch to '{e['to']}'") |
|
|
| |
| if starts: |
| reachable = _bfs(starts[0]["id"], out) |
| for n in nodes: |
| if n["id"] not in reachable: |
| res.error("F4", f"node '{n['id']}' is not reachable from start '{starts[0]['id']}'") |
|
|
|
|
| def _data_pipeline(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| out = _out_adj(edges) |
| indeg = _indegree(nodes, edges) |
| outdeg = _outdegree(nodes, edges) |
|
|
| |
| cycle = _find_cycle(nodes, out) |
| if cycle: |
| res.error("P1", f"data_pipeline must be acyclic; cycle through {' -> '.join(cycle)}") |
|
|
| |
| sources = _roles(nodes, "source") |
| sinks = _roles(nodes, "sink") |
| if not sources: |
| res.error("P2", "data_pipeline needs at least one 'source' node") |
| if not sinks: |
| res.error("P2", "data_pipeline needs at least one 'sink' node") |
|
|
| |
| for n in sources: |
| if indeg.get(n["id"], 0) > 0: |
| res.error("P3", f"source '{n['id']}' has incoming edges") |
| for n in sinks: |
| if outdeg.get(n["id"], 0) > 0: |
| res.error("P3", f"sink '{n['id']}' has outgoing edges") |
|
|
|
|
| def _sequence(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| |
| for n in nodes: |
| if n.get("role") not in ("participant", "actor"): |
| res.error("S1", f"sequence_diagram node '{n['id']}' has role '{n.get('role')}' (expected participant/actor)") |
|
|
| |
| prior_pairs = set() |
| for i, e in enumerate(raw_edges): |
| kind = (e.get("meta") or {}).get("kind") |
| if kind == "return": |
| if (e["to"], e["from"]) not in prior_pairs: |
| res.warn("S4", f"edges[{i}] is a 'return' to '{e['to']}' with no preceding message from it") |
| prior_pairs.add((e["from"], e["to"])) |
|
|
|
|
| def _er(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| |
| for n in nodes: |
| if n.get("role") != "entity": |
| res.error("E1", f"er_diagram node '{n['id']}' has role '{n.get('role')}' (expected entity)") |
| |
| for n in nodes: |
| fields = (n.get("meta") or {}).get("fields") |
| if not fields: |
| res.error("E2", f"entity '{n['id']}' declares no fields") |
| continue |
| pks = [f for f in fields if f.get("key") == "pk"] |
| if len(pks) > 1: |
| res.error("E2", f"entity '{n['id']}' has {len(pks)} primary keys (max 1)") |
| |
| for i, e in enumerate(raw_edges): |
| if not (e.get("meta") or {}).get("cardinality"): |
| res.error("E3", f"edges[{i}] ({e['from']}->{e['to']}) is missing meta.cardinality") |
|
|
|
|
| def _mind_map(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| roots = _roles(nodes, "root") |
| |
| if len(roots) != 1: |
| res.error("M1", f"mind_map needs exactly one 'root' node, found {len(roots)}") |
|
|
| n = len(nodes) |
| |
| if len(edges) != n - 1: |
| res.error("M2", f"mind_map is not a tree: {n} nodes need exactly {n-1} edges, found {len(edges)}") |
| undirected = _undirected_adj(edges) |
| if roots: |
| seen = _bfs(roots[0]["id"], undirected) |
| if len(seen) != n: |
| res.error("M2", f"mind_map is not connected: {len(seen)}/{n} nodes reachable from root (undirected)") |
| |
| indeg = _indegree(nodes, edges) |
| for nd in nodes: |
| d = indeg.get(nd["id"], 0) |
| if nd["id"] == roots[0]["id"]: |
| if d != 0: |
| res.error("M2", f"root '{nd['id']}' has {d} incoming edges (expected 0)") |
| elif d != 1: |
| res.error("M2", f"node '{nd['id']}' has {d} parents (expected exactly 1)") |
|
|
| |
| out = _out_adj(edges) |
| reach = _bfs(roots[0]["id"], out) |
| for nd in nodes: |
| if nd["id"] not in reach: |
| res.error("M3", f"node '{nd['id']}' is not reachable from root (directed)") |
|
|
|
|
| def _timeline(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| |
| for n in nodes: |
| date = (n.get("meta") or {}).get("date") |
| if not date: |
| res.error("T1", f"timeline node '{n['id']}' is missing meta.date") |
| elif _date_sort_key(date) is None: |
| res.warn("T2", f"timeline node '{n['id']}' date {date!r} is not obviously sortable") |
|
|
|
|
| def _wireframe(nodes, edges, raw_edges, by_id, id_set, groups, res): |
| |
| for n in nodes: |
| if not (n.get("meta") or {}).get("kind"): |
| res.error("W1", f"wireframe node '{n['id']}' is missing meta.kind") |
| |
| if not groups: |
| res.error("W2", "mobile_wireframe has no groups (screens)") |
| for n in nodes: |
| if not n.get("group"): |
| res.error("W2", f"wireframe node '{n['id']}' is not assigned to a screen group") |
|
|
|
|
| def _size_guards(nodes, edges, res): |
| |
| if len(nodes) > 25: |
| res.warn("G1", f"{len(nodes)} nodes — layout quality tends to degrade past ~25") |
| if nodes and len(edges) > len(nodes) * 3: |
| res.warn("G3", f"{len(edges)} edges for {len(nodes)} nodes (>3x) — likely too dense") |
|
|
|
|
| |
| |
| |
|
|
| def _dups(seq): |
| seen, dups = set(), [] |
| for x in seq: |
| if x in seen and x not in dups: |
| dups.append(x) |
| seen.add(x) |
| return dups |
|
|
|
|
| def _out_adj(edges): |
| adj = {} |
| for e in edges: |
| adj.setdefault(e["from"], []).append(e["to"]) |
| return adj |
|
|
|
|
| def _undirected_adj(edges): |
| adj = {} |
| for e in edges: |
| adj.setdefault(e["from"], []).append(e["to"]) |
| adj.setdefault(e["to"], []).append(e["from"]) |
| return adj |
|
|
|
|
| def _indegree(nodes, edges): |
| deg = {n["id"]: 0 for n in nodes} |
| for e in edges: |
| deg[e["to"]] = deg.get(e["to"], 0) + 1 |
| return deg |
|
|
|
|
| def _outdegree(nodes, edges): |
| deg = {n["id"]: 0 for n in nodes} |
| for e in edges: |
| deg[e["from"]] = deg.get(e["from"], 0) + 1 |
| return deg |
|
|
|
|
| def _bfs(start, adj): |
| seen, stack = set(), [start] |
| while stack: |
| cur = stack.pop() |
| if cur in seen: |
| continue |
| seen.add(cur) |
| for nxt in adj.get(cur, []): |
| if nxt not in seen: |
| stack.append(nxt) |
| return seen |
|
|
|
|
| def _find_cycle(nodes, adj): |
| """Return a node sequence describing a directed cycle, or None. DFS with colors.""" |
| WHITE, GRAY, BLACK = 0, 1, 2 |
| color = {n["id"]: WHITE for n in nodes} |
| parent = {} |
|
|
| def visit(u): |
| color[u] = GRAY |
| for v in adj.get(u, []): |
| if v not in color: |
| continue |
| if color[v] == WHITE: |
| parent[v] = u |
| r = visit(v) |
| if r: |
| return r |
| elif color[v] == GRAY: |
| path = [u] |
| x = u |
| while x != v and x in parent: |
| x = parent[x] |
| path.append(x) |
| path.reverse() |
| return path + [v] |
| color[u] = BLACK |
| return None |
|
|
| sys.setrecursionlimit(10000) |
| for n in nodes: |
| if color[n["id"]] == WHITE: |
| r = visit(n["id"]) |
| if r: |
| return r |
| return None |
|
|
|
|
| _DATE_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$") |
|
|
|
|
| def _date_sort_key(date): |
| """Return a sortable key for common date forms, or None if not obviously sortable. |
| Accepts ISO-ish (YYYY, YYYY-MM, YYYY-MM-DD) and 'Q<n> YYYY' style labels.""" |
| date = date.strip() |
| if _DATE_RE.match(date): |
| return date |
| m = re.match(r"^Q([1-4])\s+(\d{4})$", date) |
| if m: |
| return f"{m.group(2)}-Q{m.group(1)}" |
| return None |
|
|
|
|
| |
| |
| |
|
|
| _SCHEMA_CACHE = None |
|
|
|
|
| def load_schema(path=SCHEMA_FILE): |
| global _SCHEMA_CACHE |
| if _SCHEMA_CACHE is None: |
| with open(path, "r", encoding="utf-8") as f: |
| _SCHEMA_CACHE = json.load(f) |
| return _SCHEMA_CACHE |
|
|
|
|
| def validate_dsl(dsl, schema=None): |
| """Validate a parsed DSL object. Returns a Result (res.ok == accepted).""" |
| res = Result() |
| schema = schema if schema is not None else load_schema() |
|
|
| |
| |
| structural_errors = SchemaValidator(schema).validate(dsl) |
| if structural_errors: |
| for code, msg in structural_errors: |
| res.error(code, msg) |
| return res |
|
|
| |
| _semantic(dsl, res) |
| return res |
|
|
|
|
| def validate_dsl_text(text, schema=None): |
| """Validate DSL given as a JSON string (e.g. a model's assistant output).""" |
| res = Result() |
| try: |
| dsl = json.loads(text) |
| except json.JSONDecodeError as e: |
| res.error("json", f"not valid JSON: {e}") |
| return res |
| if not isinstance(dsl, dict): |
| res.error("json", f"top-level DSL must be an object, got {_pytype(dsl)}") |
| return res |
| return validate_dsl(dsl, schema) |
|
|
|
|
| def _assistant_content(row): |
| """Pull the assistant DSL string out of a chat-format training row.""" |
| msgs = row.get("messages") |
| if not isinstance(msgs, list): |
| return None |
| for m in reversed(msgs): |
| if isinstance(m, dict) and m.get("role") == "assistant": |
| return m.get("content") |
| return None |
|
|
|
|
| |
| |
| |
|
|
| def _read(path): |
| if path == "-": |
| return sys.stdin.read() |
| with open(path, "r", encoding="utf-8") as f: |
| return f.read() |
|
|
|
|
| def _run_single(path, show_warnings): |
| res = validate_dsl_text(_read(path)) |
| status = "PASS" if res.ok else "FAIL" |
| print(f"{status}: {path}") |
| for code, msg in res.errors: |
| print(f" ERROR [{code}] {msg}") |
| if show_warnings: |
| for code, msg in res.warnings: |
| print(f" WARN [{code}] {msg}") |
| return 0 if res.ok else 1 |
|
|
|
|
| def _run_jsonl(path, show_warnings, max_show): |
| text = _read(path) |
| total = passed = failed = warned = 0 |
| shown = 0 |
| error_codes = {} |
| for lineno, line in enumerate(text.splitlines(), 1): |
| line = line.strip() |
| if not line: |
| continue |
| total += 1 |
| try: |
| row = json.loads(line) |
| except json.JSONDecodeError as e: |
| failed += 1 |
| error_codes["json"] = error_codes.get("json", 0) + 1 |
| if shown < max_show: |
| print(f"FAIL line {lineno}: row is not valid JSON: {e}") |
| shown += 1 |
| continue |
|
|
| content = _assistant_content(row) if isinstance(row, dict) else None |
| |
| res = validate_dsl_text(content) if content is not None else validate_dsl(row) \ |
| if isinstance(row, dict) else Result() |
| if content is None and not isinstance(row, dict): |
| res = Result() |
| res.error("json", "line is neither a chat row nor a DSL object") |
|
|
| if res.warnings: |
| warned += 1 |
| for code, _ in res.errors: |
| error_codes[code] = error_codes.get(code, 0) + 1 |
|
|
| if res.ok: |
| passed += 1 |
| if show_warnings and res.warnings and shown < max_show: |
| print(f"WARN line {lineno}:") |
| for code, msg in res.warnings: |
| print(f" WARN [{code}] {msg}") |
| shown += 1 |
| else: |
| failed += 1 |
| if shown < max_show: |
| print(f"FAIL line {lineno}:") |
| for code, msg in res.errors: |
| print(f" ERROR [{code}] {msg}") |
| shown += 1 |
|
|
| print("-" * 60) |
| print(f"total={total} passed={passed} failed={failed} with_warnings={warned}") |
| if error_codes: |
| breakdown = " ".join(f"{c}={n}" for c, n in sorted(error_codes.items(), key=lambda x: -x[1])) |
| print(f"error breakdown: {breakdown}") |
| return 0 if failed == 0 else 1 |
|
|
|
|
| def main(argv=None): |
| ap = argparse.ArgumentParser(description="Validate Diagram DSL (structural + semantic gate).") |
| ap.add_argument("input", help="DSL JSON file, JSONL dataset, or '-' for stdin") |
| ap.add_argument("--jsonl", action="store_true", |
| help="treat input as a JSONL dataset; validate each row's assistant DSL") |
| ap.add_argument("--warnings", action="store_true", help="also show warnings") |
| ap.add_argument("--max-show", type=int, default=20, |
| help="max failing/warning rows to print in --jsonl mode (default 20)") |
| args = ap.parse_args(argv) |
|
|
| if args.jsonl: |
| return _run_jsonl(args.input, args.warnings, args.max_show) |
| return _run_single(args.input, args.warnings) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|