feat: uploaded the first version of excali-draw
Browse files- app.py +218 -0
- convert_dsl_to_excalidraw.py +679 -0
- dsl_schema.json +269 -0
- packages.txt +1 -0
- requirements.txt +2 -0
- validate_dsl.py +757 -0
app.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio front-end for the Excalidraw-DSL pipeline (the HF Space).
|
| 3 |
+
|
| 4 |
+
Flow per request:
|
| 5 |
+
prompt --> Modal GPU endpoint --> DSL string
|
| 6 |
+
--> strip code fences --> validate_dsl (one retry if invalid)
|
| 7 |
+
--> Converter --> Excalidraw scene JSON
|
| 8 |
+
--> rendered live in an embedded (read-only) Excalidraw canvas
|
| 9 |
+
--> downloadable .excalidraw file
|
| 10 |
+
|
| 11 |
+
The GPU work (generation) is remote on Modal; everything here is free CPU work. The validator
|
| 12 |
+
and converter are the SAME modules used in training/eval — imported, not reimplemented.
|
| 13 |
+
|
| 14 |
+
Files this Space needs (upload alongside this one):
|
| 15 |
+
app.py requirements.txt packages.txt
|
| 16 |
+
convert_dsl_to_excalidraw.py validate_dsl.py dsl_schema.json
|
| 17 |
+
|
| 18 |
+
Space env vars (Settings -> Variables and secrets):
|
| 19 |
+
MODEL_ENDPOINT the deployed Modal URL (…-model-generate.modal.run) [required]
|
| 20 |
+
MODEL_ENDPOINT_KEY Modal-Key — only if you enabled proxy auth [optional]
|
| 21 |
+
MODEL_ENDPOINT_SECRET Modal-Secret— only if you enabled proxy auth [optional]
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import html
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import sys
|
| 29 |
+
import tempfile
|
| 30 |
+
import pathlib
|
| 31 |
+
|
| 32 |
+
import requests
|
| 33 |
+
import gradio as gr
|
| 34 |
+
|
| 35 |
+
# Import the canonical validator + converter. They sit next to this file on the Space; one dir
|
| 36 |
+
# up in the local repo. Add both so it works in either layout.
|
| 37 |
+
ROOT = pathlib.Path(__file__).resolve().parent
|
| 38 |
+
for p in (ROOT, ROOT.parent):
|
| 39 |
+
if str(p) not in sys.path:
|
| 40 |
+
sys.path.insert(0, str(p))
|
| 41 |
+
|
| 42 |
+
from validate_dsl import load_schema, validate_dsl_text # noqa: E402
|
| 43 |
+
from convert_dsl_to_excalidraw import Converter # noqa: E402
|
| 44 |
+
|
| 45 |
+
SCHEMA = load_schema()
|
| 46 |
+
ENDPOINT = os.environ.get("MODEL_ENDPOINT", "").strip()
|
| 47 |
+
ENDPOINT_KEY = os.environ.get("MODEL_ENDPOINT_KEY", "").strip()
|
| 48 |
+
ENDPOINT_SECRET = os.environ.get("MODEL_ENDPOINT_SECRET", "").strip()
|
| 49 |
+
|
| 50 |
+
EXCALIDRAW_VERSION = "0.17.6" # UMD build pinned for the embedded read-only canvas
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------------- model call
|
| 54 |
+
def call_model(prompt: str, temperature: float = 0.0) -> str:
|
| 55 |
+
if not ENDPOINT:
|
| 56 |
+
raise RuntimeError(
|
| 57 |
+
"MODEL_ENDPOINT is not set. Deploy serve/modal_infer.py and put its URL in the "
|
| 58 |
+
"Space's environment variables."
|
| 59 |
+
)
|
| 60 |
+
headers = {"content-type": "application/json"}
|
| 61 |
+
if ENDPOINT_KEY and ENDPOINT_SECRET: # only when proxy auth is enabled
|
| 62 |
+
headers["Modal-Key"] = ENDPOINT_KEY
|
| 63 |
+
headers["Modal-Secret"] = ENDPOINT_SECRET
|
| 64 |
+
r = requests.post(
|
| 65 |
+
ENDPOINT,
|
| 66 |
+
headers=headers,
|
| 67 |
+
json={"prompt": prompt, "temperature": temperature},
|
| 68 |
+
timeout=180, # generous: covers a cold start of the scaled-to-zero container
|
| 69 |
+
)
|
| 70 |
+
r.raise_for_status()
|
| 71 |
+
body = r.json()
|
| 72 |
+
if "error" in body:
|
| 73 |
+
raise RuntimeError(f"model endpoint error: {body['error']}")
|
| 74 |
+
return body["dsl"]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# --------------------------------------------------------------------------- DSL cleanup
|
| 78 |
+
_FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def strip_fences(text: str) -> str:
|
| 82 |
+
"""Models occasionally wrap output in ```json fences despite the system prompt."""
|
| 83 |
+
text = _FENCE.sub("", text.strip())
|
| 84 |
+
# Keep only the outermost JSON object if there is trailing prose.
|
| 85 |
+
start, end = text.find("{"), text.rfind("}")
|
| 86 |
+
if start != -1 and end != -1 and end > start:
|
| 87 |
+
text = text[start:end + 1]
|
| 88 |
+
return text.strip()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_valid_dsl(prompt: str):
|
| 92 |
+
"""Returns (dsl_obj, dsl_text, status_md). Retries once with sampling if the first
|
| 93 |
+
(greedy) attempt fails validation."""
|
| 94 |
+
attempts = [("greedy", 0.0), ("retry (sampled)", 0.4)]
|
| 95 |
+
last_err = ""
|
| 96 |
+
for label, temp in attempts:
|
| 97 |
+
raw = call_model(prompt, temperature=temp)
|
| 98 |
+
text = strip_fences(raw)
|
| 99 |
+
res = validate_dsl_text(text, SCHEMA)
|
| 100 |
+
if res.ok:
|
| 101 |
+
warn = (f" · {len(res.warnings)} warning(s)" if res.warnings else "")
|
| 102 |
+
return json.loads(text), text, f"✅ valid DSL ({label}){warn}"
|
| 103 |
+
last_err = ", ".join(c for c, _ in res.errors) or "unparseable"
|
| 104 |
+
# Both attempts failed — surface the raw text so the user can see what happened.
|
| 105 |
+
return None, text, f"❌ DSL failed validation: {last_err}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# --------------------------------------------------------------------------- render
|
| 109 |
+
def render_scene_html(scene: dict) -> str:
|
| 110 |
+
"""An <iframe> hosting a read-only Excalidraw canvas initialised with `scene`."""
|
| 111 |
+
scene_json = json.dumps(scene)
|
| 112 |
+
doc = f"""<!DOCTYPE html>
|
| 113 |
+
<html>
|
| 114 |
+
<head>
|
| 115 |
+
<meta charset="utf-8"/>
|
| 116 |
+
<style>html,body,#root{{margin:0;padding:0;height:100%;width:100%}}</style>
|
| 117 |
+
<script>window.EXCALIDRAW_ASSET_PATH="https://unpkg.com/@excalidraw/excalidraw@{EXCALIDRAW_VERSION}/dist/";</script>
|
| 118 |
+
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
| 119 |
+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
| 120 |
+
<script crossorigin src="https://unpkg.com/@excalidraw/excalidraw@{EXCALIDRAW_VERSION}/dist/excalidraw.production.min.js"></script>
|
| 121 |
+
</head>
|
| 122 |
+
<body>
|
| 123 |
+
<div id="root"></div>
|
| 124 |
+
<script>
|
| 125 |
+
const scene = {scene_json};
|
| 126 |
+
const App = () => React.createElement(ExcalidrawLib.Excalidraw, {{
|
| 127 |
+
initialData: {{
|
| 128 |
+
elements: scene.elements || [],
|
| 129 |
+
appState: Object.assign({{}}, scene.appState, {{viewModeEnabled: true}}),
|
| 130 |
+
files: scene.files || {{}},
|
| 131 |
+
scrollToContent: true,
|
| 132 |
+
}},
|
| 133 |
+
viewModeEnabled: true,
|
| 134 |
+
UIOptions: {{canvasActions: {{export: false, saveToActiveFile: false}}}},
|
| 135 |
+
}});
|
| 136 |
+
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(App));
|
| 137 |
+
</script>
|
| 138 |
+
</body>
|
| 139 |
+
</html>"""
|
| 140 |
+
srcdoc = html.escape(doc, quote=True)
|
| 141 |
+
return (
|
| 142 |
+
f'<iframe title="excalidraw preview" '
|
| 143 |
+
f'style="width:100%;height:600px;border:1px solid #ddd;border-radius:8px" '
|
| 144 |
+
f'srcdoc="{srcdoc}"></iframe>'
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def write_excalidraw_file(scene: dict) -> str:
|
| 149 |
+
tmp = tempfile.NamedTemporaryFile(
|
| 150 |
+
mode="w", suffix=".excalidraw", delete=False, encoding="utf-8"
|
| 151 |
+
)
|
| 152 |
+
json.dump(scene, tmp, ensure_ascii=False, indent=2)
|
| 153 |
+
tmp.close()
|
| 154 |
+
return tmp.name
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# --------------------------------------------------------------------------- main handler
|
| 158 |
+
EMPTY_PREVIEW = '<div style="height:600px;display:flex;align-items:center;justify-content:center;color:#999;border:1px dashed #ccc;border-radius:8px">Your diagram preview will appear here</div>'
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def generate(prompt: str):
|
| 162 |
+
prompt = (prompt or "").strip()
|
| 163 |
+
if not prompt:
|
| 164 |
+
return EMPTY_PREVIEW, "", None, "Enter a prompt to start."
|
| 165 |
+
try:
|
| 166 |
+
obj, dsl_text, status = get_valid_dsl(prompt)
|
| 167 |
+
except Exception as e: # noqa: BLE001 — show the user what broke
|
| 168 |
+
return EMPTY_PREVIEW, "", None, f"❌ {type(e).__name__}: {e}"
|
| 169 |
+
|
| 170 |
+
if obj is None:
|
| 171 |
+
return EMPTY_PREVIEW, dsl_text, None, status
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
scene = Converter(obj).build()
|
| 175 |
+
except Exception as e: # noqa: BLE001
|
| 176 |
+
return EMPTY_PREVIEW, dsl_text, None, f"{status}, but converter failed: {e}"
|
| 177 |
+
|
| 178 |
+
html_preview = render_scene_html(scene)
|
| 179 |
+
file_path = write_excalidraw_file(scene)
|
| 180 |
+
return html_preview, dsl_text, file_path, f"{status} · {len(scene.get('elements', []))} elements"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --------------------------------------------------------------------------- UI
|
| 184 |
+
EXAMPLES = [
|
| 185 |
+
"Left-to-right architecture for a food delivery app: customer app, API gateway, order service, payment service, Redis cache, Postgres, delivery worker.",
|
| 186 |
+
"Flowchart for a user login flow with validation and a retry on failure.",
|
| 187 |
+
"ER diagram for a blog: users, posts, comments, tags.",
|
| 188 |
+
"Sequence diagram for a checkout: client, API, payment gateway, database.",
|
| 189 |
+
"Data pipeline: ingest events from Kafka, transform in Spark, load into a warehouse, dashboard on top.",
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
with gr.Blocks(title="Excalidraw DSL Generator") as demo:
|
| 193 |
+
gr.Markdown(
|
| 194 |
+
"# 🖍️ Excalidraw Diagram Generator\n"
|
| 195 |
+
"Describe a diagram → fine-tuned model emits compact DSL → deterministic converter "
|
| 196 |
+
"produces a real `.excalidraw` file. Preview is live and editable after download."
|
| 197 |
+
)
|
| 198 |
+
with gr.Row():
|
| 199 |
+
with gr.Column(scale=2):
|
| 200 |
+
prompt = gr.Textbox(
|
| 201 |
+
label="Describe your diagram",
|
| 202 |
+
placeholder="e.g. architecture for a URL shortener with cache and worker",
|
| 203 |
+
lines=3,
|
| 204 |
+
)
|
| 205 |
+
go = gr.Button("Generate", variant="primary")
|
| 206 |
+
status = gr.Markdown("")
|
| 207 |
+
gr.Examples(examples=EXAMPLES, inputs=prompt)
|
| 208 |
+
with gr.Column(scale=3):
|
| 209 |
+
preview = gr.HTML(EMPTY_PREVIEW)
|
| 210 |
+
download = gr.DownloadButton("⬇️ Download .excalidraw", interactive=True)
|
| 211 |
+
with gr.Accordion("Generated DSL (JSON)", open=False):
|
| 212 |
+
dsl_view = gr.Code(language="json", label="DSL")
|
| 213 |
+
|
| 214 |
+
go.click(generate, inputs=prompt, outputs=[preview, dsl_view, download, status])
|
| 215 |
+
prompt.submit(generate, inputs=prompt, outputs=[preview, dsl_view, download, status])
|
| 216 |
+
|
| 217 |
+
if __name__ == "__main__":
|
| 218 |
+
demo.launch()
|
convert_dsl_to_excalidraw.py
ADDED
|
@@ -0,0 +1,679 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
convert_dsl_to_excalidraw.py
|
| 4 |
+
|
| 5 |
+
Deterministic converter: Diagram DSL (see dsl.schema.json / DSL_SPEC.md) -> .excalidraw JSON.
|
| 6 |
+
|
| 7 |
+
Design:
|
| 8 |
+
- Graph family (system_architecture, flowchart, data_pipeline, er_diagram) is laid out by
|
| 9 |
+
Graphviz `dot` (called as a subprocess with -Tjson). Only dependency: the `dot` binary.
|
| 10 |
+
- sequence_diagram, timeline, mind_map, mobile_wireframe use hand-rolled layouts.
|
| 11 |
+
- A shared emit layer builds valid Excalidraw elements (shapes, bound text, bound arrows),
|
| 12 |
+
handles z-ordering, and self-validates the output (no dangling refs, overlap warnings).
|
| 13 |
+
|
| 14 |
+
The DSL carries ONLY semantic decisions. Everything geometric/stylistic is computed here.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python3 convert_dsl_to_excalidraw.py input.dsl.json -o output.excalidraw
|
| 18 |
+
cat input.dsl.json | python3 convert_dsl_to_excalidraw.py - > output.excalidraw
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import hashlib
|
| 23 |
+
import json
|
| 24 |
+
import math
|
| 25 |
+
import subprocess
|
| 26 |
+
import sys
|
| 27 |
+
|
| 28 |
+
# --------------------------------------------------------------------------------------
|
| 29 |
+
# Constants
|
| 30 |
+
# --------------------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
FONT = 20
|
| 33 |
+
TITLE_FONT = 28
|
| 34 |
+
FONT_FAMILY = 1 # 1 = Excalidraw hand-drawn (Virgil)
|
| 35 |
+
LINE_HEIGHT = 1.25
|
| 36 |
+
CHAR_W = 0.60 # avg glyph width as a fraction of font size (approx for Virgil)
|
| 37 |
+
PAD_X = 22
|
| 38 |
+
PAD_Y = 16
|
| 39 |
+
NODE_MIN_W = 120
|
| 40 |
+
NODE_MAX_W = 320
|
| 41 |
+
NODE_MIN_H = 56
|
| 42 |
+
INK = "#1e1e1e"
|
| 43 |
+
TRANSPARENT = "transparent"
|
| 44 |
+
WHITE = "#ffffff"
|
| 45 |
+
|
| 46 |
+
# role -> (shape, fill, stroke, rounded, dashed)
|
| 47 |
+
# shape in {"rectangle","ellipse","diamond"}. Mirrors the table in DSL_SPEC.md.
|
| 48 |
+
ROLE_STYLE = {
|
| 49 |
+
"actor": ("ellipse", "#e9ecef", "#343a40", False, False),
|
| 50 |
+
"client": ("rectangle", "#a5d8ff", "#1971c2", False, False),
|
| 51 |
+
"service": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
|
| 52 |
+
"gateway": ("diamond", "#d0bfff", "#7048e8", False, False),
|
| 53 |
+
"database": ("rectangle", "#a5d8ff", "#1971c2", False, False),
|
| 54 |
+
"cache": ("rectangle", "#ffd8a8", "#e8590c", True, False),
|
| 55 |
+
"queue": ("rectangle", "#ffec99", "#f08c00", False, False),
|
| 56 |
+
"worker": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
|
| 57 |
+
"storage": ("rectangle", "#e9ecef", "#495057", False, False),
|
| 58 |
+
"external": ("rectangle", "#f1f3f5", "#868e96", False, True),
|
| 59 |
+
"monitoring": ("rectangle", "#ffec99", "#f08c00", False, False),
|
| 60 |
+
"auth": ("rectangle", "#d0bfff", "#7048e8", False, False),
|
| 61 |
+
"start": ("ellipse", "#b2f2bb", "#2f9e44", False, False),
|
| 62 |
+
"end": ("ellipse", "#ffec99", "#e8590c", False, False),
|
| 63 |
+
"process": ("rectangle", "#a5d8ff", "#1971c2", True, False),
|
| 64 |
+
"decision": ("diamond", "#ffec99", "#f08c00", False, False),
|
| 65 |
+
"io": ("rectangle", "#e9ecef", "#495057", False, False),
|
| 66 |
+
"source": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
|
| 67 |
+
"transform": ("rectangle", "#a5d8ff", "#1971c2", True, False),
|
| 68 |
+
"sink": ("rectangle", "#d0bfff", "#7048e8", True, False),
|
| 69 |
+
"entity": ("rectangle", WHITE, "#1e1e1e", False, False),
|
| 70 |
+
"participant": ("rectangle", "#a5d8ff", "#1971c2", False, False),
|
| 71 |
+
"milestone": ("ellipse", "#a5d8ff", "#1971c2", False, False),
|
| 72 |
+
"root": ("rectangle", "#d0bfff", "#7048e8", True, False),
|
| 73 |
+
"branch": ("rectangle", "#a5d8ff", "#1971c2", True, False),
|
| 74 |
+
"leaf": ("rectangle", "#b2f2bb", "#2f9e44", True, False),
|
| 75 |
+
"screen": ("rectangle", TRANSPARENT, "#343a40", True, False),
|
| 76 |
+
"ui_element": ("rectangle", "#e9ecef", "#495057", False, False),
|
| 77 |
+
"component": ("rectangle", "#e9ecef", "#495057", True, False),
|
| 78 |
+
"generic": ("rectangle", "#e9ecef", "#495057", False, False),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
COLOR_OVERRIDE = {
|
| 82 |
+
"red": ("#ffc9c9", "#e03131"),
|
| 83 |
+
"orange": ("#ffd8a8", "#e8590c"),
|
| 84 |
+
"yellow": ("#ffec99", "#f08c00"),
|
| 85 |
+
"green": ("#b2f2bb", "#2f9e44"),
|
| 86 |
+
"blue": ("#a5d8ff", "#1971c2"),
|
| 87 |
+
"violet": ("#d0bfff", "#7048e8"),
|
| 88 |
+
"gray": ("#e9ecef", "#495057"),
|
| 89 |
+
"black": ("#e9ecef", "#1e1e1e"),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
STROKE_STYLE = {"solid": "solid", "dashed": "dashed", "dotted": "dotted"}
|
| 93 |
+
|
| 94 |
+
DEFAULT_DIRECTION = {
|
| 95 |
+
"system_architecture": "LR",
|
| 96 |
+
"flowchart": "TB",
|
| 97 |
+
"data_pipeline": "LR",
|
| 98 |
+
"er_diagram": "LR",
|
| 99 |
+
"sequence_diagram": "LR",
|
| 100 |
+
"timeline": "LR",
|
| 101 |
+
"mind_map": "radial",
|
| 102 |
+
"mobile_wireframe": "TB",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
GRAPH_FAMILY = {"system_architecture", "flowchart", "data_pipeline", "er_diagram"}
|
| 106 |
+
|
| 107 |
+
# --------------------------------------------------------------------------------------
|
| 108 |
+
# Text measurement / sizing
|
| 109 |
+
# --------------------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
def text_dims(text, font=FONT):
|
| 112 |
+
lines = str(text).split("\n")
|
| 113 |
+
w = max((len(l) for l in lines), default=1) * font * CHAR_W
|
| 114 |
+
h = len(lines) * font * LINE_HEIGHT
|
| 115 |
+
return max(w, font * CHAR_W), max(h, font * LINE_HEIGHT)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def node_size(label):
|
| 119 |
+
tw, th = text_dims(label, FONT)
|
| 120 |
+
w = min(max(tw + 2 * PAD_X, NODE_MIN_W), NODE_MAX_W)
|
| 121 |
+
h = max(th + 2 * PAD_Y, NODE_MIN_H)
|
| 122 |
+
return w, h
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# --------------------------------------------------------------------------------------
|
| 126 |
+
# Converter
|
| 127 |
+
# --------------------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
class Converter:
|
| 130 |
+
def __init__(self, dsl, seed=None):
|
| 131 |
+
self.dsl = dsl
|
| 132 |
+
self.diagram = dsl["diagram"]
|
| 133 |
+
self.title = dsl["title"]
|
| 134 |
+
self.direction = dsl.get("direction") or DEFAULT_DIRECTION[self.diagram]
|
| 135 |
+
self.nodes = dsl.get("nodes", [])
|
| 136 |
+
self.edges = dsl.get("edges", [])
|
| 137 |
+
self.groups = dsl.get("groups", [])
|
| 138 |
+
self.node_by_id = {n["id"]: n for n in self.nodes}
|
| 139 |
+
|
| 140 |
+
if seed is None:
|
| 141 |
+
h = hashlib.md5((self.diagram + "|" + self.title).encode()).hexdigest()
|
| 142 |
+
seed = int(h[:8], 16)
|
| 143 |
+
self._seed = seed
|
| 144 |
+
self._rng_state = seed & 0x7FFFFFFF
|
| 145 |
+
|
| 146 |
+
self.elements = [] # accumulates element dicts (with temporary "_z")
|
| 147 |
+
self.box_elem = {} # node_id -> shape element dict (to append boundElements)
|
| 148 |
+
self.boxes = {} # node_id -> (x, y, w, h)
|
| 149 |
+
self._idc = 0
|
| 150 |
+
|
| 151 |
+
# ---- deterministic helpers -------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
def _rand(self):
|
| 154 |
+
# simple LCG so output is reproducible without Date.now()/random module state
|
| 155 |
+
self._rng_state = (self._rng_state * 1103515245 + 12345) & 0x7FFFFFFF
|
| 156 |
+
return self._rng_state
|
| 157 |
+
|
| 158 |
+
def _new_id(self, prefix):
|
| 159 |
+
self._idc += 1
|
| 160 |
+
return f"{prefix}_{self._idc}"
|
| 161 |
+
|
| 162 |
+
def _base(self, typ, x, y, w, h, z):
|
| 163 |
+
return {
|
| 164 |
+
"id": None, "type": typ,
|
| 165 |
+
"x": round(x, 2), "y": round(y, 2),
|
| 166 |
+
"width": round(w, 2), "height": round(h, 2),
|
| 167 |
+
"angle": 0,
|
| 168 |
+
"strokeColor": INK, "backgroundColor": TRANSPARENT,
|
| 169 |
+
"fillStyle": "solid", "strokeWidth": 2, "strokeStyle": "solid",
|
| 170 |
+
"roughness": 1, "opacity": 100,
|
| 171 |
+
"groupIds": [], "frameId": None, "roundness": None,
|
| 172 |
+
"seed": self._rand(), "version": 1, "versionNonce": self._rand(),
|
| 173 |
+
"isDeleted": False, "boundElements": [], "updated": 1,
|
| 174 |
+
"link": None, "locked": False,
|
| 175 |
+
"_z": z,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
def _add(self, el):
|
| 179 |
+
self.elements.append(el)
|
| 180 |
+
return el
|
| 181 |
+
|
| 182 |
+
# ---- element builders ------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def add_box(self, node_id, x, y, w, h, role, color_override=None, z=2):
|
| 185 |
+
shape, fill, stroke, rounded, dashed = ROLE_STYLE.get(role, ROLE_STYLE["generic"])
|
| 186 |
+
if color_override and color_override in COLOR_OVERRIDE:
|
| 187 |
+
fill, stroke = COLOR_OVERRIDE[color_override]
|
| 188 |
+
el = self._base(shape, x, y, w, h, z)
|
| 189 |
+
el["id"] = self._new_id("sh")
|
| 190 |
+
el["backgroundColor"] = fill
|
| 191 |
+
el["strokeColor"] = stroke
|
| 192 |
+
if rounded and shape == "rectangle":
|
| 193 |
+
el["roundness"] = {"type": 3}
|
| 194 |
+
if dashed:
|
| 195 |
+
el["strokeStyle"] = "dashed"
|
| 196 |
+
self._add(el)
|
| 197 |
+
if node_id is not None:
|
| 198 |
+
self.box_elem[node_id] = el
|
| 199 |
+
self.boxes[node_id] = (x, y, w, h)
|
| 200 |
+
return el
|
| 201 |
+
|
| 202 |
+
def add_label(self, container_el, text, align="center", valign="middle",
|
| 203 |
+
font=FONT, color=INK, z=3, cx=None, cy=None):
|
| 204 |
+
tw, th = text_dims(text, font)
|
| 205 |
+
bx, by = container_el["x"], container_el["y"]
|
| 206 |
+
bw, bh = container_el["width"], container_el["height"]
|
| 207 |
+
if cx is None:
|
| 208 |
+
if align == "left":
|
| 209 |
+
tx = bx + PAD_X
|
| 210 |
+
else:
|
| 211 |
+
tx = bx + (bw - tw) / 2
|
| 212 |
+
else:
|
| 213 |
+
tx = cx - tw / 2
|
| 214 |
+
if cy is None:
|
| 215 |
+
if valign == "top":
|
| 216 |
+
ty = by + PAD_Y
|
| 217 |
+
else:
|
| 218 |
+
ty = by + (bh - th) / 2
|
| 219 |
+
else:
|
| 220 |
+
ty = cy - th / 2
|
| 221 |
+
el = self._base("text", tx, ty, tw, th, z)
|
| 222 |
+
el["id"] = self._new_id("tx")
|
| 223 |
+
el["strokeColor"] = color
|
| 224 |
+
el.update({
|
| 225 |
+
"fontSize": font, "fontFamily": FONT_FAMILY,
|
| 226 |
+
"text": str(text), "originalText": str(text),
|
| 227 |
+
"textAlign": align, "verticalAlign": valign,
|
| 228 |
+
"containerId": container_el["id"], "lineHeight": LINE_HEIGHT,
|
| 229 |
+
"autoResize": True,
|
| 230 |
+
})
|
| 231 |
+
container_el["boundElements"].append({"type": "text", "id": el["id"]})
|
| 232 |
+
self._add(el)
|
| 233 |
+
return el
|
| 234 |
+
|
| 235 |
+
def add_free_text(self, text, cx, top, font=FONT, color=INK, z=4):
|
| 236 |
+
tw, th = text_dims(text, font)
|
| 237 |
+
el = self._base("text", cx - tw / 2, top, tw, th, z)
|
| 238 |
+
el["id"] = self._new_id("tx")
|
| 239 |
+
el["strokeColor"] = color
|
| 240 |
+
el.update({
|
| 241 |
+
"fontSize": font, "fontFamily": FONT_FAMILY,
|
| 242 |
+
"text": str(text), "originalText": str(text),
|
| 243 |
+
"textAlign": "center", "verticalAlign": "top",
|
| 244 |
+
"containerId": None, "lineHeight": LINE_HEIGHT, "autoResize": True,
|
| 245 |
+
})
|
| 246 |
+
self._add(el)
|
| 247 |
+
return el
|
| 248 |
+
|
| 249 |
+
def _linear(self, typ, pts, style="solid", arrowhead=True,
|
| 250 |
+
stroke=INK, src_id=None, dst_id=None, z=1):
|
| 251 |
+
x0, y0 = pts[0]
|
| 252 |
+
rel = [[round(px - x0, 2), round(py - y0, 2)] for px, py in pts]
|
| 253 |
+
xs = [p[0] for p in rel]
|
| 254 |
+
ys = [p[1] for p in rel]
|
| 255 |
+
el = self._base(typ, x0, y0, max(xs) - min(xs), max(ys) - min(ys), z)
|
| 256 |
+
el["id"] = self._new_id("ln" if typ == "line" else "ar")
|
| 257 |
+
el["strokeColor"] = stroke
|
| 258 |
+
el["strokeStyle"] = STROKE_STYLE.get(style, "solid")
|
| 259 |
+
el["roundness"] = {"type": 2}
|
| 260 |
+
el["points"] = rel
|
| 261 |
+
el["lastCommittedPoint"] = None
|
| 262 |
+
el["startArrowhead"] = None
|
| 263 |
+
el["endArrowhead"] = "arrow" if (arrowhead and typ == "arrow") else None
|
| 264 |
+
el["startBinding"] = None
|
| 265 |
+
el["endBinding"] = None
|
| 266 |
+
if src_id is not None and src_id in self.box_elem:
|
| 267 |
+
el["startBinding"] = {"elementId": self.box_elem[src_id]["id"], "focus": 0, "gap": 4}
|
| 268 |
+
self.box_elem[src_id]["boundElements"].append({"type": "arrow", "id": el["id"]})
|
| 269 |
+
if dst_id is not None and dst_id in self.box_elem:
|
| 270 |
+
el["endBinding"] = {"elementId": self.box_elem[dst_id]["id"], "focus": 0, "gap": 4}
|
| 271 |
+
self.box_elem[dst_id]["boundElements"].append({"type": "arrow", "id": el["id"]})
|
| 272 |
+
self._add(el)
|
| 273 |
+
return el
|
| 274 |
+
|
| 275 |
+
def add_arrow(self, pts, label=None, style="solid", src_id=None, dst_id=None,
|
| 276 |
+
arrowhead=True, stroke=INK):
|
| 277 |
+
el = self._linear("arrow", pts, style, arrowhead, stroke, src_id, dst_id)
|
| 278 |
+
if label:
|
| 279 |
+
mid = pts[len(pts) // 2]
|
| 280 |
+
self.add_label(el, label, cx=mid[0], cy=mid[1], font=16)
|
| 281 |
+
return el
|
| 282 |
+
|
| 283 |
+
def add_line(self, pts, style="solid", stroke=INK):
|
| 284 |
+
return self._linear("line", pts, style, arrowhead=False, stroke=stroke)
|
| 285 |
+
|
| 286 |
+
def add_frame(self, x, y, w, h, label=None, stroke="#868e96"):
|
| 287 |
+
el = self._base("rectangle", x, y, w, h, z=0)
|
| 288 |
+
el["id"] = self._new_id("fr")
|
| 289 |
+
el["strokeColor"] = stroke
|
| 290 |
+
el["strokeStyle"] = "dashed"
|
| 291 |
+
el["roundness"] = {"type": 3}
|
| 292 |
+
self._add(el)
|
| 293 |
+
if label:
|
| 294 |
+
self.add_free_text(label, x + 70, y + 8, font=16, color=stroke, z=1)
|
| 295 |
+
return el
|
| 296 |
+
|
| 297 |
+
# ---- geometry --------------------------------------------------------------------
|
| 298 |
+
|
| 299 |
+
@staticmethod
|
| 300 |
+
def _clip(box, toward):
|
| 301 |
+
"""Point on box border in the direction of `toward` (center of another box)."""
|
| 302 |
+
x, y, w, h = box
|
| 303 |
+
cx, cy = x + w / 2, y + h / 2
|
| 304 |
+
dx, dy = toward[0] - cx, toward[1] - cy
|
| 305 |
+
if dx == 0 and dy == 0:
|
| 306 |
+
return (cx, cy)
|
| 307 |
+
hw, hh = w / 2, h / 2
|
| 308 |
+
scale = 1.0 / max(abs(dx) / hw if hw else 1e9, abs(dy) / hh if hh else 1e9)
|
| 309 |
+
return (cx + dx * scale, cy + dy * scale)
|
| 310 |
+
|
| 311 |
+
def _center(self, node_id):
|
| 312 |
+
x, y, w, h = self.boxes[node_id]
|
| 313 |
+
return (x + w / 2, y + h / 2)
|
| 314 |
+
|
| 315 |
+
# ----------------------------------------------------------------------------------
|
| 316 |
+
# Graphviz-backed layout (graph family)
|
| 317 |
+
# ----------------------------------------------------------------------------------
|
| 318 |
+
|
| 319 |
+
def build_graph_family(self):
|
| 320 |
+
OFFX, OFFY = 80, 120
|
| 321 |
+
rankdir = self.direction if self.direction in ("LR", "RL", "TB", "BT") else "TB"
|
| 322 |
+
|
| 323 |
+
# precompute node render sizes
|
| 324 |
+
sizes = {}
|
| 325 |
+
labels = {}
|
| 326 |
+
for n in self.nodes:
|
| 327 |
+
if self.diagram == "er_diagram":
|
| 328 |
+
label = self._er_label(n)
|
| 329 |
+
else:
|
| 330 |
+
label = n["label"]
|
| 331 |
+
labels[n["id"]] = label
|
| 332 |
+
sizes[n["id"]] = node_size(label) if self.diagram != "er_diagram" \
|
| 333 |
+
else self._er_size(label)
|
| 334 |
+
|
| 335 |
+
# build dot source
|
| 336 |
+
src = ["digraph G {",
|
| 337 |
+
f' graph [rankdir={rankdir}, nodesep=0.5, ranksep=0.9, splines=true];',
|
| 338 |
+
' node [shape=box, fixedsize=true];']
|
| 339 |
+
group_members = {g["id"]: [] for g in self.groups}
|
| 340 |
+
for n in self.nodes:
|
| 341 |
+
if n.get("group") in group_members:
|
| 342 |
+
group_members[n["group"]].append(n["id"])
|
| 343 |
+
grouped = set()
|
| 344 |
+
for g in self.groups:
|
| 345 |
+
members = group_members.get(g["id"], [])
|
| 346 |
+
if not members:
|
| 347 |
+
continue
|
| 348 |
+
src.append(f' subgraph cluster_{g["id"]} {{')
|
| 349 |
+
src.append(f' label="{self._esc(g["label"])}"; style=dashed; color="#868e96";')
|
| 350 |
+
for nid in members:
|
| 351 |
+
w, h = sizes[nid]
|
| 352 |
+
src.append(f' "{nid}" [width={w/72:.3f}, height={h/72:.3f}, label=""];')
|
| 353 |
+
grouped.add(nid)
|
| 354 |
+
src.append(" }")
|
| 355 |
+
for n in self.nodes:
|
| 356 |
+
if n["id"] in grouped:
|
| 357 |
+
continue
|
| 358 |
+
w, h = sizes[n["id"]]
|
| 359 |
+
src.append(f' "{n["id"]}" [width={w/72:.3f}, height={h/72:.3f}, label=""];')
|
| 360 |
+
for e in self.edges:
|
| 361 |
+
src.append(f' "{e["from"]}" -> "{e["to"]}";')
|
| 362 |
+
src.append("}")
|
| 363 |
+
dot_src = "\n".join(src)
|
| 364 |
+
|
| 365 |
+
data = self._run_dot(dot_src)
|
| 366 |
+
bb = [float(v) for v in data["bb"].split(",")]
|
| 367 |
+
H = bb[3]
|
| 368 |
+
|
| 369 |
+
def flip(x, y):
|
| 370 |
+
return (x + OFFX, (H - y) + OFFY)
|
| 371 |
+
|
| 372 |
+
# nodes + clusters
|
| 373 |
+
gvid_name = {}
|
| 374 |
+
for o in data.get("objects", []):
|
| 375 |
+
name = o.get("name", "")
|
| 376 |
+
gvid_name[o.get("_gvid")] = name
|
| 377 |
+
if name.startswith("cluster_") and "bb" in o:
|
| 378 |
+
x0, y0, x1, y1 = [float(v) for v in o["bb"].split(",")]
|
| 379 |
+
fx0, fy1 = flip(x0, y0)
|
| 380 |
+
fx1, fy0 = flip(x1, y1)
|
| 381 |
+
glabel = o.get("label") or ""
|
| 382 |
+
self.add_frame(min(fx0, fx1), min(fy0, fy1),
|
| 383 |
+
abs(fx1 - fx0), abs(fy1 - fy0), label=glabel)
|
| 384 |
+
elif name in self.node_by_id and "pos" in o:
|
| 385 |
+
px, py = [float(v) for v in o["pos"].split(",")]
|
| 386 |
+
cx, cy = flip(px, py)
|
| 387 |
+
w, h = sizes[name]
|
| 388 |
+
node = self.node_by_id[name]
|
| 389 |
+
box = self.add_box(name, cx - w / 2, cy - h / 2, w, h,
|
| 390 |
+
node["role"], node.get("color"))
|
| 391 |
+
if self.diagram == "er_diagram":
|
| 392 |
+
self.add_label(box, labels[name], align="left", valign="top")
|
| 393 |
+
else:
|
| 394 |
+
self.add_label(box, labels[name])
|
| 395 |
+
|
| 396 |
+
# edges (match output edges back to DSL edges for label/style)
|
| 397 |
+
pending = {}
|
| 398 |
+
for e in self.edges:
|
| 399 |
+
pending.setdefault((e["from"], e["to"]), []).append(e)
|
| 400 |
+
for oe in data.get("edges", []):
|
| 401 |
+
frm = gvid_name.get(oe.get("tail"))
|
| 402 |
+
to = gvid_name.get(oe.get("head"))
|
| 403 |
+
attrs = {}
|
| 404 |
+
q = pending.get((frm, to))
|
| 405 |
+
if q:
|
| 406 |
+
attrs = q.pop(0)
|
| 407 |
+
pts = self._spline(oe.get("pos", ""), flip)
|
| 408 |
+
if len(pts) < 2:
|
| 409 |
+
pts = [self._center(frm), self._center(to)]
|
| 410 |
+
label = attrs.get("label")
|
| 411 |
+
if self.diagram == "er_diagram" and attrs.get("meta", {}).get("cardinality"):
|
| 412 |
+
label = attrs["meta"]["cardinality"] + (f" {label}" if label else "")
|
| 413 |
+
self.add_arrow(pts, label=label, style=attrs.get("style", "solid"),
|
| 414 |
+
src_id=frm, dst_id=to)
|
| 415 |
+
|
| 416 |
+
@staticmethod
|
| 417 |
+
def _spline(pos, flip):
|
| 418 |
+
start = end = None
|
| 419 |
+
mids = []
|
| 420 |
+
for tok in pos.split():
|
| 421 |
+
if tok.startswith("e,"):
|
| 422 |
+
x, y = tok[2:].split(",")
|
| 423 |
+
end = flip(float(x), float(y))
|
| 424 |
+
elif tok.startswith("s,"):
|
| 425 |
+
x, y = tok[2:].split(",")
|
| 426 |
+
start = flip(float(x), float(y))
|
| 427 |
+
elif "," in tok:
|
| 428 |
+
x, y = tok.split(",")
|
| 429 |
+
mids.append(flip(float(x), float(y)))
|
| 430 |
+
poly = []
|
| 431 |
+
if start:
|
| 432 |
+
poly.append(start)
|
| 433 |
+
poly.extend(mids)
|
| 434 |
+
if end:
|
| 435 |
+
poly.append(end)
|
| 436 |
+
return poly
|
| 437 |
+
|
| 438 |
+
def _er_label(self, node):
|
| 439 |
+
lines = [node["label"]]
|
| 440 |
+
for f in node.get("meta", {}).get("fields", []):
|
| 441 |
+
key = f.get("key", "none")
|
| 442 |
+
tag = {"pk": "🔑 ", "fk": "↗ "}.get(key, "")
|
| 443 |
+
t = f.get("type", "")
|
| 444 |
+
lines.append(f"{tag}{f['name']}" + (f": {t}" if t else ""))
|
| 445 |
+
return "\n".join(lines)
|
| 446 |
+
|
| 447 |
+
@staticmethod
|
| 448 |
+
def _er_size(label):
|
| 449 |
+
tw, th = text_dims(label, FONT)
|
| 450 |
+
return min(max(tw + 2 * PAD_X, 160), 360), th + 2 * PAD_Y
|
| 451 |
+
|
| 452 |
+
@staticmethod
|
| 453 |
+
def _esc(s):
|
| 454 |
+
return str(s).replace("\\", "\\\\").replace('"', '\\"')
|
| 455 |
+
|
| 456 |
+
@staticmethod
|
| 457 |
+
def _run_dot(src):
|
| 458 |
+
try:
|
| 459 |
+
p = subprocess.run(["dot", "-Tjson"], input=src.encode(),
|
| 460 |
+
capture_output=True, timeout=30)
|
| 461 |
+
except FileNotFoundError:
|
| 462 |
+
raise SystemExit("ERROR: graphviz `dot` not found. Install graphviz.")
|
| 463 |
+
if p.returncode != 0:
|
| 464 |
+
raise SystemExit("ERROR: dot failed:\n" + p.stderr.decode())
|
| 465 |
+
return json.loads(p.stdout.decode())
|
| 466 |
+
|
| 467 |
+
# ----------------------------------------------------------------------------------
|
| 468 |
+
# Hand-rolled layouts
|
| 469 |
+
# ----------------------------------------------------------------------------------
|
| 470 |
+
|
| 471 |
+
def build_sequence(self):
|
| 472 |
+
MX, TOP = 100, 100
|
| 473 |
+
gap = max((node_size(n["label"])[0] for n in self.nodes), default=160) + 80
|
| 474 |
+
part_x = {}
|
| 475 |
+
box_h = 50
|
| 476 |
+
for i, n in enumerate(self.nodes):
|
| 477 |
+
w, _ = node_size(n["label"])
|
| 478 |
+
cx = MX + i * gap
|
| 479 |
+
part_x[n["id"]] = cx
|
| 480 |
+
box = self.add_box(n["id"], cx - w / 2, TOP, w, box_h, n["role"], n.get("color"))
|
| 481 |
+
self.add_label(box, n["label"])
|
| 482 |
+
n_msgs = max(len(self.edges), 1)
|
| 483 |
+
life_bottom = TOP + box_h + 60 + n_msgs * 55 + 40
|
| 484 |
+
for nid, cx in part_x.items():
|
| 485 |
+
self.add_line([(cx, TOP + box_h), (cx, life_bottom)], style="dashed", stroke="#adb5bd")
|
| 486 |
+
y = TOP + box_h + 60
|
| 487 |
+
for e in self.edges:
|
| 488 |
+
x1, x2 = part_x[e["from"]], part_x[e["to"]]
|
| 489 |
+
kind = e.get("meta", {}).get("kind", "sync")
|
| 490 |
+
style = "dashed" if kind in ("async", "return") else "solid"
|
| 491 |
+
self.add_arrow([(x1, y), (x2, y)], label=e.get("label"), style=style)
|
| 492 |
+
y += 55
|
| 493 |
+
|
| 494 |
+
def build_timeline(self):
|
| 495 |
+
nodes = sorted(self.nodes, key=lambda n: str(n.get("meta", {}).get("date", "")))
|
| 496 |
+
MX, AXIS = 120, 320
|
| 497 |
+
gap = max((node_size(n["label"])[0] for n in nodes), default=160) + 60
|
| 498 |
+
xs = [MX + i * gap for i in range(len(nodes))]
|
| 499 |
+
if xs:
|
| 500 |
+
self.add_arrow([(MX - 40, AXIS), (xs[-1] + 60, AXIS)], stroke="#495057")
|
| 501 |
+
for i, n in enumerate(nodes):
|
| 502 |
+
cx = xs[i]
|
| 503 |
+
marker = self.add_box(n["id"], cx - 9, AXIS - 9, 18, 18, "milestone", n.get("color"))
|
| 504 |
+
above = (i % 2 == 0)
|
| 505 |
+
w, h = node_size(n["label"])
|
| 506 |
+
by = AXIS - 70 - h if above else AXIS + 70
|
| 507 |
+
box = self.add_box(None, cx - w / 2, by, w, h, "generic", n.get("color"))
|
| 508 |
+
self.add_label(box, n["label"])
|
| 509 |
+
date = n.get("meta", {}).get("date", "")
|
| 510 |
+
if date:
|
| 511 |
+
self.add_free_text(str(date), cx, AXIS + (-95 if not above else 75), font=14,
|
| 512 |
+
color="#868e96")
|
| 513 |
+
connector_y = (by + h, AXIS) if above else (by, AXIS)
|
| 514 |
+
self.add_line([(cx, connector_y[0]), (cx, connector_y[1])], stroke="#adb5bd")
|
| 515 |
+
|
| 516 |
+
def build_mind_map(self):
|
| 517 |
+
roots = [n for n in self.nodes if n["role"] == "root"] or self.nodes[:1]
|
| 518 |
+
root = roots[0]
|
| 519 |
+
adj = {n["id"]: [] for n in self.nodes}
|
| 520 |
+
for e in self.edges:
|
| 521 |
+
adj[e["from"]].append(e["to"])
|
| 522 |
+
adj[e["to"]].append(e["from"])
|
| 523 |
+
CX, CY = 700, 450
|
| 524 |
+
placed = {root["id"]: (CX, CY)}
|
| 525 |
+
order = [(root["id"], 0.0, 2 * math.pi, 0)]
|
| 526 |
+
visited = {root["id"]}
|
| 527 |
+
edges_to_draw = []
|
| 528 |
+
while order:
|
| 529 |
+
nid, a0, a1, depth = order.pop(0)
|
| 530 |
+
children = [c for c in adj[nid] if c not in visited]
|
| 531 |
+
if not children:
|
| 532 |
+
continue
|
| 533 |
+
span = (a1 - a0) / len(children)
|
| 534 |
+
r = (depth + 1) * 230
|
| 535 |
+
for i, c in enumerate(children):
|
| 536 |
+
visited.add(c)
|
| 537 |
+
ang = a0 + span * (i + 0.5)
|
| 538 |
+
cx, cy = CX + r * math.cos(ang), CY + r * math.sin(ang)
|
| 539 |
+
placed[c] = (cx, cy)
|
| 540 |
+
edges_to_draw.append((nid, c))
|
| 541 |
+
order.append((c, a0 + span * i, a0 + span * (i + 1), depth + 1))
|
| 542 |
+
for n in self.nodes:
|
| 543 |
+
cx, cy = placed.get(n["id"], (CX, CY))
|
| 544 |
+
w, h = node_size(n["label"])
|
| 545 |
+
role = "root" if n["id"] == root["id"] else n["role"]
|
| 546 |
+
box = self.add_box(n["id"], cx - w / 2, cy - h / 2, w, h, role, n.get("color"))
|
| 547 |
+
self.add_label(box, n["label"])
|
| 548 |
+
for frm, to in edges_to_draw:
|
| 549 |
+
p1 = self._clip(self.boxes[frm], self._center(to))
|
| 550 |
+
p2 = self._clip(self.boxes[to], self._center(frm))
|
| 551 |
+
self.add_arrow([p1, p2], arrowhead=False, src_id=frm, dst_id=to, stroke="#868e96")
|
| 552 |
+
|
| 553 |
+
def build_wireframe(self):
|
| 554 |
+
screens = self.groups or [{"id": "__all__", "label": self.dsl.get("title", "Screen")}]
|
| 555 |
+
members = {g["id"]: [] for g in screens}
|
| 556 |
+
for n in self.nodes:
|
| 557 |
+
g = n.get("group", "__all__")
|
| 558 |
+
members.setdefault(g, []).append(n)
|
| 559 |
+
FW, FH, MX, TOP, GAP = 300, 600, 100, 120, 60
|
| 560 |
+
kind_h = {"header": 56, "nav": 48, "tab": 44, "footer": 56, "button": 50,
|
| 561 |
+
"input": 50, "text": 40, "image": 140, "list": 200, "card": 120}
|
| 562 |
+
for si, sc in enumerate(screens):
|
| 563 |
+
fx = MX + si * (FW + GAP)
|
| 564 |
+
self.add_frame(fx, TOP, FW, FH, label=sc["label"], stroke="#343a40")
|
| 565 |
+
y = TOP + 20
|
| 566 |
+
for n in members.get(sc["id"], []):
|
| 567 |
+
kind = n.get("meta", {}).get("kind", "text")
|
| 568 |
+
h = kind_h.get(kind, 50)
|
| 569 |
+
box = self.add_box(n["id"], fx + 16, y, FW - 32, h, "ui_element", n.get("color"))
|
| 570 |
+
self.add_label(box, n["label"])
|
| 571 |
+
y += h + 14
|
| 572 |
+
|
| 573 |
+
# ----------------------------------------------------------------------------------
|
| 574 |
+
# Finalize
|
| 575 |
+
# ----------------------------------------------------------------------------------
|
| 576 |
+
|
| 577 |
+
def _content_bbox(self):
|
| 578 |
+
xs0, ys0, xs1, ys1 = [], [], [], []
|
| 579 |
+
for el in self.elements:
|
| 580 |
+
xs0.append(el["x"]); ys0.append(el["y"])
|
| 581 |
+
xs1.append(el["x"] + el["width"]); ys1.append(el["y"] + el["height"])
|
| 582 |
+
if not xs0:
|
| 583 |
+
return (0, 0, 200, 200)
|
| 584 |
+
return (min(xs0), min(ys0), max(xs1), max(ys1))
|
| 585 |
+
|
| 586 |
+
def build(self):
|
| 587 |
+
if self.diagram in GRAPH_FAMILY:
|
| 588 |
+
self.build_graph_family()
|
| 589 |
+
elif self.diagram == "sequence_diagram":
|
| 590 |
+
self.build_sequence()
|
| 591 |
+
elif self.diagram == "timeline":
|
| 592 |
+
self.build_timeline()
|
| 593 |
+
elif self.diagram == "mind_map":
|
| 594 |
+
self.build_mind_map()
|
| 595 |
+
elif self.diagram == "mobile_wireframe":
|
| 596 |
+
self.build_wireframe()
|
| 597 |
+
else:
|
| 598 |
+
raise SystemExit(f"Unsupported diagram type: {self.diagram}")
|
| 599 |
+
|
| 600 |
+
x0, y0, x1, _ = self._content_bbox()
|
| 601 |
+
self.add_free_text(self.title, (x0 + x1) / 2, y0 - 60, font=TITLE_FONT)
|
| 602 |
+
|
| 603 |
+
self._assign_z_and_index()
|
| 604 |
+
self._self_validate()
|
| 605 |
+
return {
|
| 606 |
+
"type": "excalidraw",
|
| 607 |
+
"version": 2,
|
| 608 |
+
"source": "convert_dsl_to_excalidraw.py",
|
| 609 |
+
"elements": [self._strip(e) for e in self.elements],
|
| 610 |
+
"appState": {"viewBackgroundColor": WHITE, "gridSize": None},
|
| 611 |
+
"files": {},
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
def _assign_z_and_index(self):
|
| 615 |
+
# stable sort by z layer, then assign fractional index in that order
|
| 616 |
+
order = sorted(range(len(self.elements)), key=lambda i: (self.elements[i]["_z"], i))
|
| 617 |
+
self.elements = [self.elements[i] for i in order]
|
| 618 |
+
for i, el in enumerate(self.elements):
|
| 619 |
+
el["index"] = f"a{i:07d}"
|
| 620 |
+
|
| 621 |
+
@staticmethod
|
| 622 |
+
def _strip(el):
|
| 623 |
+
el = dict(el)
|
| 624 |
+
el.pop("_z", None)
|
| 625 |
+
return el
|
| 626 |
+
|
| 627 |
+
def _self_validate(self):
|
| 628 |
+
ids = {el["id"] for el in self.elements}
|
| 629 |
+
warns = []
|
| 630 |
+
for el in self.elements:
|
| 631 |
+
for be in el.get("boundElements", []):
|
| 632 |
+
if be["id"] not in ids:
|
| 633 |
+
warns.append(f"dangling boundElement {be['id']} on {el['id']}")
|
| 634 |
+
if el.get("containerId") and el["containerId"] not in ids:
|
| 635 |
+
warns.append(f"dangling containerId {el['containerId']} on {el['id']}")
|
| 636 |
+
for b in ("startBinding", "endBinding"):
|
| 637 |
+
if el.get(b) and el[b]["elementId"] not in ids:
|
| 638 |
+
warns.append(f"dangling {b} on {el['id']}")
|
| 639 |
+
# overlap warning between node boxes
|
| 640 |
+
items = list(self.boxes.items())
|
| 641 |
+
for i in range(len(items)):
|
| 642 |
+
for j in range(i + 1, len(items)):
|
| 643 |
+
a, b = items[i][1], items[j][1]
|
| 644 |
+
ox = max(0, min(a[0] + a[2], b[0] + b[2]) - max(a[0], b[0]))
|
| 645 |
+
oy = max(0, min(a[1] + a[3], b[1] + b[3]) - max(a[1], b[1]))
|
| 646 |
+
if ox > 5 and oy > 5:
|
| 647 |
+
warns.append(f"overlap: {items[i][0]} & {items[j][0]}")
|
| 648 |
+
if warns:
|
| 649 |
+
sys.stderr.write("[self-validate] " + str(len(warns)) + " warning(s):\n")
|
| 650 |
+
for w in warns[:20]:
|
| 651 |
+
sys.stderr.write(" - " + w + "\n")
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
# --------------------------------------------------------------------------------------
|
| 655 |
+
# CLI
|
| 656 |
+
# --------------------------------------------------------------------------------------
|
| 657 |
+
|
| 658 |
+
def main():
|
| 659 |
+
ap = argparse.ArgumentParser(description="Convert Diagram DSL JSON to .excalidraw")
|
| 660 |
+
ap.add_argument("input", help="DSL json file, or '-' for stdin")
|
| 661 |
+
ap.add_argument("-o", "--output", help="output .excalidraw (default: stdout)")
|
| 662 |
+
ap.add_argument("--seed", type=int, default=None, help="override deterministic seed")
|
| 663 |
+
args = ap.parse_args()
|
| 664 |
+
|
| 665 |
+
raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
|
| 666 |
+
dsl = json.loads(raw)
|
| 667 |
+
|
| 668 |
+
out = Converter(dsl, seed=args.seed).build()
|
| 669 |
+
text = json.dumps(out, indent=2, ensure_ascii=False)
|
| 670 |
+
if args.output:
|
| 671 |
+
with open(args.output, "w", encoding="utf-8") as f:
|
| 672 |
+
f.write(text)
|
| 673 |
+
sys.stderr.write(f"wrote {args.output} ({len(out['elements'])} elements)\n")
|
| 674 |
+
else:
|
| 675 |
+
sys.stdout.write(text)
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
if __name__ == "__main__":
|
| 679 |
+
main()
|
dsl_schema.json
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$id": "https://excali-ft/dsl.schema.json",
|
| 4 |
+
"title": "Excalidraw Diagram DSL",
|
| 5 |
+
"description": "Compact, semantic-only description of a diagram. Carries planning decisions (what exists, what connects, what type). All geometry, sizing, IDs, colors, and Excalidraw format fields are computed deterministically by the converter and MUST NOT appear here.",
|
| 6 |
+
"type": "object",
|
| 7 |
+
"additionalProperties": false,
|
| 8 |
+
"required": ["diagram", "title", "nodes"],
|
| 9 |
+
"properties": {
|
| 10 |
+
"diagram": {
|
| 11 |
+
"description": "Selects the converter strategy and default layout.",
|
| 12 |
+
"type": "string",
|
| 13 |
+
"enum": [
|
| 14 |
+
"system_architecture",
|
| 15 |
+
"flowchart",
|
| 16 |
+
"er_diagram",
|
| 17 |
+
"sequence_diagram",
|
| 18 |
+
"data_pipeline",
|
| 19 |
+
"timeline",
|
| 20 |
+
"mind_map",
|
| 21 |
+
"mobile_wireframe"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
"title": {
|
| 25 |
+
"description": "Human-readable diagram title, rendered as a heading text element.",
|
| 26 |
+
"type": "string",
|
| 27 |
+
"minLength": 1,
|
| 28 |
+
"maxLength": 80
|
| 29 |
+
},
|
| 30 |
+
"direction": {
|
| 31 |
+
"description": "Primary flow/layout direction. Optional; the converter applies a per-diagram default when omitted (e.g. TB for flowchart, LR for architecture, radial for mind_map).",
|
| 32 |
+
"type": "string",
|
| 33 |
+
"enum": ["LR", "RL", "TB", "BT", "radial"]
|
| 34 |
+
},
|
| 35 |
+
"nodes": {
|
| 36 |
+
"description": "The boxes/entities/participants/milestones. Internal `id`s are reference handles only and are never rendered.",
|
| 37 |
+
"type": "array",
|
| 38 |
+
"minItems": 1,
|
| 39 |
+
"maxItems": 40,
|
| 40 |
+
"items": { "$ref": "#/$defs/node" }
|
| 41 |
+
},
|
| 42 |
+
"edges": {
|
| 43 |
+
"description": "Connections between nodes. For sequence_diagram, array order is message order. May be empty (e.g. some timelines/mind maps without explicit links).",
|
| 44 |
+
"type": "array",
|
| 45 |
+
"maxItems": 80,
|
| 46 |
+
"items": { "$ref": "#/$defs/edge" },
|
| 47 |
+
"default": []
|
| 48 |
+
},
|
| 49 |
+
"groups": {
|
| 50 |
+
"description": "Optional clusters/swimlanes/screens. A node joins a group via node.group = group.id.",
|
| 51 |
+
"type": "array",
|
| 52 |
+
"maxItems": 12,
|
| 53 |
+
"items": { "$ref": "#/$defs/group" },
|
| 54 |
+
"default": []
|
| 55 |
+
},
|
| 56 |
+
"meta": {
|
| 57 |
+
"description": "Diagram-level escape hatch for type-specific knobs. Keep empty unless a type below defines a key.",
|
| 58 |
+
"type": "object",
|
| 59 |
+
"additionalProperties": true,
|
| 60 |
+
"default": {}
|
| 61 |
+
}
|
| 62 |
+
},
|
| 63 |
+
|
| 64 |
+
"$defs": {
|
| 65 |
+
"id": {
|
| 66 |
+
"description": "Stable snake_case identifier, unique within its collection. Reference handle only — not rendered.",
|
| 67 |
+
"type": "string",
|
| 68 |
+
"pattern": "^[a-z][a-z0-9_]*$",
|
| 69 |
+
"minLength": 1,
|
| 70 |
+
"maxLength": 40
|
| 71 |
+
},
|
| 72 |
+
"label": {
|
| 73 |
+
"description": "Visible text. Non-empty, single line preferred.",
|
| 74 |
+
"type": "string",
|
| 75 |
+
"minLength": 1,
|
| 76 |
+
"maxLength": 60
|
| 77 |
+
},
|
| 78 |
+
"role": {
|
| 79 |
+
"description": "Semantic kind of the node. The converter maps role -> {shape, fill color, stroke color}. The model never picks shapes or colors directly.",
|
| 80 |
+
"type": "string",
|
| 81 |
+
"enum": [
|
| 82 |
+
"actor",
|
| 83 |
+
"client",
|
| 84 |
+
"service",
|
| 85 |
+
"gateway",
|
| 86 |
+
"database",
|
| 87 |
+
"cache",
|
| 88 |
+
"queue",
|
| 89 |
+
"worker",
|
| 90 |
+
"storage",
|
| 91 |
+
"external",
|
| 92 |
+
"monitoring",
|
| 93 |
+
"auth",
|
| 94 |
+
"start",
|
| 95 |
+
"end",
|
| 96 |
+
"process",
|
| 97 |
+
"decision",
|
| 98 |
+
"io",
|
| 99 |
+
"source",
|
| 100 |
+
"transform",
|
| 101 |
+
"sink",
|
| 102 |
+
"entity",
|
| 103 |
+
"participant",
|
| 104 |
+
"milestone",
|
| 105 |
+
"root",
|
| 106 |
+
"branch",
|
| 107 |
+
"leaf",
|
| 108 |
+
"screen",
|
| 109 |
+
"ui_element",
|
| 110 |
+
"component",
|
| 111 |
+
"generic"
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
"node": {
|
| 115 |
+
"type": "object",
|
| 116 |
+
"additionalProperties": false,
|
| 117 |
+
"required": ["id", "label", "role"],
|
| 118 |
+
"properties": {
|
| 119 |
+
"id": { "$ref": "#/$defs/id" },
|
| 120 |
+
"label": { "$ref": "#/$defs/label" },
|
| 121 |
+
"role": { "$ref": "#/$defs/role" },
|
| 122 |
+
"group": {
|
| 123 |
+
"description": "Optional group membership; must equal some groups[].id.",
|
| 124 |
+
"$ref": "#/$defs/id"
|
| 125 |
+
},
|
| 126 |
+
"color": {
|
| 127 |
+
"description": "OPTIONAL explicit override only when a human would intentionally choose a color (e.g. emphasis). Otherwise omit and let the converter derive color from role.",
|
| 128 |
+
"type": "string",
|
| 129 |
+
"enum": ["red", "orange", "yellow", "green", "blue", "violet", "gray", "black"]
|
| 130 |
+
},
|
| 131 |
+
"meta": {
|
| 132 |
+
"description": "Type-specific node data. See per-diagram rules in DSL_SPEC.md. Examples: er_diagram fields, timeline date, wireframe ui kind.",
|
| 133 |
+
"type": "object",
|
| 134 |
+
"additionalProperties": false,
|
| 135 |
+
"properties": {
|
| 136 |
+
"fields": {
|
| 137 |
+
"description": "er_diagram only: entity columns.",
|
| 138 |
+
"type": "array",
|
| 139 |
+
"maxItems": 20,
|
| 140 |
+
"items": {
|
| 141 |
+
"type": "object",
|
| 142 |
+
"additionalProperties": false,
|
| 143 |
+
"required": ["name"],
|
| 144 |
+
"properties": {
|
| 145 |
+
"name": { "type": "string", "minLength": 1, "maxLength": 40 },
|
| 146 |
+
"type": { "type": "string", "maxLength": 30 },
|
| 147 |
+
"key": { "type": "string", "enum": ["pk", "fk", "none"], "default": "none" }
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"date": {
|
| 152 |
+
"description": "timeline only: ISO-ish date or short label used for ordering and display.",
|
| 153 |
+
"type": "string",
|
| 154 |
+
"maxLength": 30
|
| 155 |
+
},
|
| 156 |
+
"kind": {
|
| 157 |
+
"description": "mobile_wireframe only: the UI element kind.",
|
| 158 |
+
"type": "string",
|
| 159 |
+
"enum": ["header", "nav", "text", "input", "button", "list", "image", "card", "tab", "footer"]
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
},
|
| 165 |
+
"edge": {
|
| 166 |
+
"type": "object",
|
| 167 |
+
"additionalProperties": false,
|
| 168 |
+
"required": ["from", "to"],
|
| 169 |
+
"properties": {
|
| 170 |
+
"from": {
|
| 171 |
+
"description": "Source node id. Must reference an existing node.",
|
| 172 |
+
"$ref": "#/$defs/id"
|
| 173 |
+
},
|
| 174 |
+
"to": {
|
| 175 |
+
"description": "Target node id. Must reference an existing node.",
|
| 176 |
+
"$ref": "#/$defs/id"
|
| 177 |
+
},
|
| 178 |
+
"label": {
|
| 179 |
+
"description": "Optional edge label. For flowchart decision branches use 'yes'/'no'.",
|
| 180 |
+
"type": "string",
|
| 181 |
+
"maxLength": 40
|
| 182 |
+
},
|
| 183 |
+
"style": {
|
| 184 |
+
"description": "Line style. Default solid.",
|
| 185 |
+
"type": "string",
|
| 186 |
+
"enum": ["solid", "dashed", "dotted"],
|
| 187 |
+
"default": "solid"
|
| 188 |
+
},
|
| 189 |
+
"meta": {
|
| 190 |
+
"type": "object",
|
| 191 |
+
"additionalProperties": false,
|
| 192 |
+
"properties": {
|
| 193 |
+
"cardinality": {
|
| 194 |
+
"description": "er_diagram only: relationship cardinality.",
|
| 195 |
+
"type": "string",
|
| 196 |
+
"enum": ["1:1", "1:N", "N:1", "N:M"]
|
| 197 |
+
},
|
| 198 |
+
"kind": {
|
| 199 |
+
"description": "sequence_diagram only: message kind.",
|
| 200 |
+
"type": "string",
|
| 201 |
+
"enum": ["sync", "async", "return"],
|
| 202 |
+
"default": "sync"
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
},
|
| 208 |
+
"group": {
|
| 209 |
+
"type": "object",
|
| 210 |
+
"additionalProperties": false,
|
| 211 |
+
"required": ["id", "label"],
|
| 212 |
+
"properties": {
|
| 213 |
+
"id": { "$ref": "#/$defs/id" },
|
| 214 |
+
"label": { "$ref": "#/$defs/label" }
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
},
|
| 218 |
+
|
| 219 |
+
"allOf": [
|
| 220 |
+
{
|
| 221 |
+
"description": "ER diagrams: relationships should carry cardinality.",
|
| 222 |
+
"if": { "properties": { "diagram": { "const": "er_diagram" } } },
|
| 223 |
+
"then": {
|
| 224 |
+
"properties": {
|
| 225 |
+
"edges": {
|
| 226 |
+
"items": {
|
| 227 |
+
"properties": {
|
| 228 |
+
"meta": { "required": ["cardinality"] }
|
| 229 |
+
},
|
| 230 |
+
"required": ["meta"]
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"description": "Timelines: every milestone node should carry a date.",
|
| 238 |
+
"if": { "properties": { "diagram": { "const": "timeline" } } },
|
| 239 |
+
"then": {
|
| 240 |
+
"properties": {
|
| 241 |
+
"nodes": {
|
| 242 |
+
"items": {
|
| 243 |
+
"properties": {
|
| 244 |
+
"meta": { "required": ["date"] }
|
| 245 |
+
},
|
| 246 |
+
"required": ["meta"]
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"description": "Mobile wireframes: every node needs a UI kind.",
|
| 254 |
+
"if": { "properties": { "diagram": { "const": "mobile_wireframe" } } },
|
| 255 |
+
"then": {
|
| 256 |
+
"properties": {
|
| 257 |
+
"nodes": {
|
| 258 |
+
"items": {
|
| 259 |
+
"properties": {
|
| 260 |
+
"meta": { "required": ["kind"] }
|
| 261 |
+
},
|
| 262 |
+
"required": ["meta"]
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
]
|
| 269 |
+
}
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
graphviz
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44,<6
|
| 2 |
+
requests>=2.31
|
validate_dsl.py
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
validate_dsl.py
|
| 4 |
+
|
| 5 |
+
The Phase 2 accept/reject gate for Diagram DSL.
|
| 6 |
+
|
| 7 |
+
Two layers, both enforced here (zero pip dependencies — pure stdlib):
|
| 8 |
+
|
| 9 |
+
1. STRUCTURAL — a small JSON-Schema validator that reads `dsl_schema.json` and checks
|
| 10 |
+
types, enums, required keys, additionalProperties, lengths, patterns, and the
|
| 11 |
+
diagram-specific allOf/if-then conditionals. The schema file stays the single source
|
| 12 |
+
of truth; this validator just interprets it.
|
| 13 |
+
|
| 14 |
+
2. SEMANTIC — the rules JSON Schema cannot express (DSL_SPEC.md section 2): unique ids,
|
| 15 |
+
edge endpoints resolve, no isolated nodes, flowchart-has-one-start, pipeline-is-acyclic,
|
| 16 |
+
mind_map-is-a-tree, etc. These are the rules that actually keep the dataset clean.
|
| 17 |
+
|
| 18 |
+
A DSL is ACCEPTED iff it produces zero errors. Warnings never reject — they flag rows worth
|
| 19 |
+
a human glance (e.g. very dense graphs, heavy color overrides).
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
# validate a single DSL file (or '-' for stdin)
|
| 23 |
+
python3 validate_dsl.py diagram.dsl.json
|
| 24 |
+
cat diagram.dsl.json | python3 validate_dsl.py -
|
| 25 |
+
|
| 26 |
+
# validate every assistant DSL in a training file (the dataset gate)
|
| 27 |
+
python3 validate_dsl.py --jsonl train.jsonl
|
| 28 |
+
python3 validate_dsl.py --jsonl train.jsonl --max-show 20 --warnings
|
| 29 |
+
|
| 30 |
+
Exit code is 0 when everything passed, 1 otherwise — usable in CI / generation loops.
|
| 31 |
+
|
| 32 |
+
As a library:
|
| 33 |
+
from validate_dsl import validate_dsl, validate_dsl_text, Result
|
| 34 |
+
res = validate_dsl(dsl_dict)
|
| 35 |
+
if res.ok: ...
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
import argparse
|
| 39 |
+
import json
|
| 40 |
+
import os
|
| 41 |
+
import re
|
| 42 |
+
import sys
|
| 43 |
+
|
| 44 |
+
SCHEMA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dsl_schema.json")
|
| 45 |
+
|
| 46 |
+
# Diagram types whose every node must touch at least one edge (DSL_SPEC U7).
|
| 47 |
+
CONNECTED_TYPES = {"system_architecture", "flowchart", "data_pipeline", "mind_map"}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# --------------------------------------------------------------------------------------
|
| 51 |
+
# Result accumulator
|
| 52 |
+
# --------------------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
class Result:
|
| 55 |
+
"""Collects validation findings. `ok` is True iff there are no errors."""
|
| 56 |
+
|
| 57 |
+
def __init__(self):
|
| 58 |
+
self.errors = [] # list of (code, message)
|
| 59 |
+
self.warnings = [] # list of (code, message)
|
| 60 |
+
|
| 61 |
+
def error(self, code, message):
|
| 62 |
+
self.errors.append((code, message))
|
| 63 |
+
|
| 64 |
+
def warn(self, code, message):
|
| 65 |
+
self.warnings.append((code, message))
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def ok(self):
|
| 69 |
+
return not self.errors
|
| 70 |
+
|
| 71 |
+
def extend(self, other):
|
| 72 |
+
self.errors.extend(other.errors)
|
| 73 |
+
self.warnings.extend(other.warnings)
|
| 74 |
+
|
| 75 |
+
def __str__(self):
|
| 76 |
+
lines = []
|
| 77 |
+
for code, msg in self.errors:
|
| 78 |
+
lines.append(f" ERROR [{code}] {msg}")
|
| 79 |
+
for code, msg in self.warnings:
|
| 80 |
+
lines.append(f" WARN [{code}] {msg}")
|
| 81 |
+
if not lines:
|
| 82 |
+
lines.append(" OK")
|
| 83 |
+
return "\n".join(lines)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# --------------------------------------------------------------------------------------
|
| 87 |
+
# Layer 1: structural validation (a minimal JSON-Schema interpreter for our schema)
|
| 88 |
+
# --------------------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
_JSON_TYPES = {
|
| 91 |
+
"object": dict,
|
| 92 |
+
"array": list,
|
| 93 |
+
"string": str,
|
| 94 |
+
"number": (int, float),
|
| 95 |
+
"integer": int,
|
| 96 |
+
"boolean": bool,
|
| 97 |
+
"null": type(None),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _type_matches(value, type_name):
|
| 102 |
+
# bool is a subclass of int in Python; keep number/integer from swallowing booleans.
|
| 103 |
+
if type_name in ("number", "integer") and isinstance(value, bool):
|
| 104 |
+
return False
|
| 105 |
+
if type_name == "boolean":
|
| 106 |
+
return isinstance(value, bool)
|
| 107 |
+
return isinstance(value, _JSON_TYPES[type_name])
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class SchemaValidator:
|
| 111 |
+
"""Interprets the subset of JSON Schema 2020-12 that dsl_schema.json actually uses:
|
| 112 |
+
$ref/$defs, type, enum, const, required, properties, additionalProperties, items,
|
| 113 |
+
minItems/maxItems, minLength/maxLength, pattern, allOf, and if/then/else."""
|
| 114 |
+
|
| 115 |
+
def __init__(self, schema):
|
| 116 |
+
self.root = schema
|
| 117 |
+
|
| 118 |
+
def validate(self, instance):
|
| 119 |
+
errors = []
|
| 120 |
+
self._check(instance, self.root, "$", errors)
|
| 121 |
+
return errors
|
| 122 |
+
|
| 123 |
+
# -- internals --------------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
def _resolve(self, schema):
|
| 126 |
+
# Follow a $ref chain ("#/$defs/node"). Sibling keywords in this schema are only
|
| 127 |
+
# `description` (non-validating), so replacing the node with its target is safe.
|
| 128 |
+
seen = 0
|
| 129 |
+
while isinstance(schema, dict) and "$ref" in schema:
|
| 130 |
+
ref = schema["$ref"]
|
| 131 |
+
parts = [p for p in ref.lstrip("#/").split("/") if p]
|
| 132 |
+
target = self.root
|
| 133 |
+
for p in parts:
|
| 134 |
+
target = target[p]
|
| 135 |
+
schema = target
|
| 136 |
+
seen += 1
|
| 137 |
+
if seen > 50:
|
| 138 |
+
break
|
| 139 |
+
return schema
|
| 140 |
+
|
| 141 |
+
def _matches(self, instance, schema):
|
| 142 |
+
"""True iff `instance` validates against `schema` (used for if/then)."""
|
| 143 |
+
tmp = []
|
| 144 |
+
self._check(instance, schema, "$", tmp)
|
| 145 |
+
return not tmp
|
| 146 |
+
|
| 147 |
+
def _check(self, instance, schema, path, errors):
|
| 148 |
+
schema = self._resolve(schema)
|
| 149 |
+
if not isinstance(schema, dict):
|
| 150 |
+
return
|
| 151 |
+
|
| 152 |
+
# allOf
|
| 153 |
+
for sub in schema.get("allOf", []):
|
| 154 |
+
self._check(instance, sub, path, errors)
|
| 155 |
+
|
| 156 |
+
# if / then / else
|
| 157 |
+
if "if" in schema:
|
| 158 |
+
if self._matches(instance, schema["if"]):
|
| 159 |
+
if "then" in schema:
|
| 160 |
+
self._check(instance, schema["then"], path, errors)
|
| 161 |
+
elif "else" in schema:
|
| 162 |
+
self._check(instance, schema["else"], path, errors)
|
| 163 |
+
|
| 164 |
+
# type
|
| 165 |
+
if "type" in schema:
|
| 166 |
+
t = schema["type"]
|
| 167 |
+
types = t if isinstance(t, list) else [t]
|
| 168 |
+
if not any(_type_matches(instance, tn) for tn in types):
|
| 169 |
+
errors.append(("schema", f"{path}: expected type {t}, got {_pytype(instance)}"))
|
| 170 |
+
return # deeper keyword checks would just produce noise
|
| 171 |
+
|
| 172 |
+
# enum / const
|
| 173 |
+
if "enum" in schema and instance not in schema["enum"]:
|
| 174 |
+
errors.append(("schema", f"{path}: {instance!r} is not one of {schema['enum']}"))
|
| 175 |
+
if "const" in schema and instance != schema["const"]:
|
| 176 |
+
errors.append(("schema", f"{path}: expected const {schema['const']!r}"))
|
| 177 |
+
|
| 178 |
+
if isinstance(instance, str):
|
| 179 |
+
self._check_string(instance, schema, path, errors)
|
| 180 |
+
elif isinstance(instance, list):
|
| 181 |
+
self._check_array(instance, schema, path, errors)
|
| 182 |
+
elif isinstance(instance, dict):
|
| 183 |
+
self._check_object(instance, schema, path, errors)
|
| 184 |
+
|
| 185 |
+
def _check_string(self, instance, schema, path, errors):
|
| 186 |
+
if "minLength" in schema and len(instance) < schema["minLength"]:
|
| 187 |
+
errors.append(("schema", f"{path}: shorter than minLength {schema['minLength']}"))
|
| 188 |
+
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
| 189 |
+
errors.append(("schema", f"{path}: longer than maxLength {schema['maxLength']}"))
|
| 190 |
+
if "pattern" in schema and not re.search(schema["pattern"], instance):
|
| 191 |
+
errors.append(("schema", f"{path}: {instance!r} does not match pattern {schema['pattern']}"))
|
| 192 |
+
|
| 193 |
+
def _check_array(self, instance, schema, path, errors):
|
| 194 |
+
if "minItems" in schema and len(instance) < schema["minItems"]:
|
| 195 |
+
errors.append(("schema", f"{path}: fewer than minItems {schema['minItems']}"))
|
| 196 |
+
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
| 197 |
+
errors.append(("schema", f"{path}: more than maxItems {schema['maxItems']}"))
|
| 198 |
+
items = schema.get("items")
|
| 199 |
+
if items is not None:
|
| 200 |
+
for i, el in enumerate(instance):
|
| 201 |
+
self._check(el, items, f"{path}[{i}]", errors)
|
| 202 |
+
|
| 203 |
+
def _check_object(self, instance, schema, path, errors):
|
| 204 |
+
props = schema.get("properties", {})
|
| 205 |
+
for req in schema.get("required", []):
|
| 206 |
+
if req not in instance:
|
| 207 |
+
errors.append(("schema", f"{path}: missing required property '{req}'"))
|
| 208 |
+
additional = schema.get("additionalProperties", True)
|
| 209 |
+
for key, val in instance.items():
|
| 210 |
+
if key in props:
|
| 211 |
+
self._check(val, props[key], f"{path}.{key}", errors)
|
| 212 |
+
elif additional is False:
|
| 213 |
+
errors.append(("schema", f"{path}: unexpected property '{key}' (additionalProperties=false)"))
|
| 214 |
+
elif isinstance(additional, dict):
|
| 215 |
+
self._check(val, additional, f"{path}.{key}", errors)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _pytype(v):
|
| 219 |
+
if isinstance(v, bool):
|
| 220 |
+
return "boolean"
|
| 221 |
+
if isinstance(v, dict):
|
| 222 |
+
return "object"
|
| 223 |
+
if isinstance(v, list):
|
| 224 |
+
return "array"
|
| 225 |
+
if isinstance(v, str):
|
| 226 |
+
return "string"
|
| 227 |
+
if isinstance(v, (int, float)):
|
| 228 |
+
return "number"
|
| 229 |
+
if v is None:
|
| 230 |
+
return "null"
|
| 231 |
+
return type(v).__name__
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# --------------------------------------------------------------------------------------
|
| 235 |
+
# Layer 2: semantic validation (DSL_SPEC.md section 2)
|
| 236 |
+
# --------------------------------------------------------------------------------------
|
| 237 |
+
|
| 238 |
+
def _semantic(dsl, res):
|
| 239 |
+
diagram = dsl["diagram"]
|
| 240 |
+
nodes = dsl["nodes"]
|
| 241 |
+
edges = dsl.get("edges", []) or []
|
| 242 |
+
groups = dsl.get("groups", []) or []
|
| 243 |
+
|
| 244 |
+
node_ids = [n["id"] for n in nodes]
|
| 245 |
+
id_set = set(node_ids)
|
| 246 |
+
by_id = {n["id"]: n for n in nodes}
|
| 247 |
+
group_ids = [g["id"] for g in groups]
|
| 248 |
+
|
| 249 |
+
_universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res)
|
| 250 |
+
|
| 251 |
+
# Edges that actually resolve — later graph checks must not crash on dangling refs.
|
| 252 |
+
valid_edges = [e for e in edges if e["from"] in id_set and e["to"] in id_set]
|
| 253 |
+
|
| 254 |
+
dispatch = {
|
| 255 |
+
"flowchart": _flowchart,
|
| 256 |
+
"data_pipeline": _data_pipeline,
|
| 257 |
+
"sequence_diagram": _sequence,
|
| 258 |
+
"er_diagram": _er,
|
| 259 |
+
"mind_map": _mind_map,
|
| 260 |
+
"timeline": _timeline,
|
| 261 |
+
"mobile_wireframe": _wireframe,
|
| 262 |
+
}
|
| 263 |
+
fn = dispatch.get(diagram)
|
| 264 |
+
if fn:
|
| 265 |
+
fn(nodes, valid_edges, edges, by_id, id_set, groups, res)
|
| 266 |
+
|
| 267 |
+
_size_guards(nodes, edges, res)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _roles(nodes, role):
|
| 271 |
+
return [n for n in nodes if n.get("role") == role]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _universal(dsl, diagram, nodes, edges, groups, node_ids, id_set, group_ids, res):
|
| 275 |
+
# U1 — unique node ids
|
| 276 |
+
for dup in _dups(node_ids):
|
| 277 |
+
res.error("U1", f"duplicate node id '{dup}'")
|
| 278 |
+
# U2 — unique group ids
|
| 279 |
+
for dup in _dups(group_ids):
|
| 280 |
+
res.error("U2", f"duplicate group id '{dup}'")
|
| 281 |
+
group_set = set(group_ids)
|
| 282 |
+
|
| 283 |
+
# U3 — edge endpoints resolve
|
| 284 |
+
for i, e in enumerate(edges):
|
| 285 |
+
if e["from"] not in id_set:
|
| 286 |
+
res.error("U3", f"edges[{i}].from '{e['from']}' is not a node id")
|
| 287 |
+
if e["to"] not in id_set:
|
| 288 |
+
res.error("U3", f"edges[{i}].to '{e['to']}' is not a node id")
|
| 289 |
+
|
| 290 |
+
# U4 — group membership resolves
|
| 291 |
+
for n in nodes:
|
| 292 |
+
g = n.get("group")
|
| 293 |
+
if g is not None and g not in group_set:
|
| 294 |
+
res.error("U4", f"node '{n['id']}' references unknown group '{g}'")
|
| 295 |
+
|
| 296 |
+
# U5 — no self loops
|
| 297 |
+
for i, e in enumerate(edges):
|
| 298 |
+
if e["from"] == e["to"]:
|
| 299 |
+
res.error("U5", f"edges[{i}] is a self-loop on '{e['from']}'")
|
| 300 |
+
|
| 301 |
+
# U6 — no duplicate edges (sequence_diagram may legitimately repeat a pair)
|
| 302 |
+
if diagram != "sequence_diagram":
|
| 303 |
+
seen = set()
|
| 304 |
+
for i, e in enumerate(edges):
|
| 305 |
+
key = (e["from"], e["to"], e.get("label", ""))
|
| 306 |
+
if key in seen:
|
| 307 |
+
res.error("U6", f"edges[{i}] duplicates edge {e['from']}->{e['to']} (label {e.get('label','')!r})")
|
| 308 |
+
seen.add(key)
|
| 309 |
+
|
| 310 |
+
# U7 — no isolated nodes for connected types
|
| 311 |
+
if diagram in CONNECTED_TYPES:
|
| 312 |
+
touched = set()
|
| 313 |
+
for e in edges:
|
| 314 |
+
touched.add(e["from"])
|
| 315 |
+
touched.add(e["to"])
|
| 316 |
+
for n in nodes:
|
| 317 |
+
if n["id"] not in touched:
|
| 318 |
+
res.error("U7", f"node '{n['id']}' is isolated (no edge) in a connected diagram")
|
| 319 |
+
|
| 320 |
+
# U8 — color override sanity (warn only)
|
| 321 |
+
overrides = sum(1 for n in nodes if n.get("color"))
|
| 322 |
+
if nodes and overrides / len(nodes) > 0.30:
|
| 323 |
+
res.warn("U8", f"{overrides}/{len(nodes)} nodes override color (>30%) — styling should come from role")
|
| 324 |
+
|
| 325 |
+
# U9 — label hygiene
|
| 326 |
+
_label_hygiene("title", dsl.get("title", ""), res)
|
| 327 |
+
for n in nodes:
|
| 328 |
+
_label_hygiene(f"node '{n['id']}' label", n.get("label", ""), res)
|
| 329 |
+
for g in groups:
|
| 330 |
+
_label_hygiene(f"group '{g['id']}' label", g.get("label", ""), res)
|
| 331 |
+
for i, e in enumerate(edges):
|
| 332 |
+
if "label" in e:
|
| 333 |
+
_label_hygiene(f"edges[{i}] label", e["label"], res)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _label_hygiene(where, text, res):
|
| 337 |
+
if "\n" in text or "\r" in text:
|
| 338 |
+
res.error("U9", f"{where} contains a newline (converter handles wrapping)")
|
| 339 |
+
elif text != text.strip():
|
| 340 |
+
res.warn("U9", f"{where} has leading/trailing whitespace")
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _flowchart(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 344 |
+
starts = _roles(nodes, "start")
|
| 345 |
+
# F1 — exactly one start
|
| 346 |
+
if len(starts) != 1:
|
| 347 |
+
res.error("F1", f"flowchart needs exactly one 'start' node, found {len(starts)}")
|
| 348 |
+
# F2 — at least one end
|
| 349 |
+
if not _roles(nodes, "end"):
|
| 350 |
+
res.error("F2", "flowchart needs at least one 'end' node")
|
| 351 |
+
|
| 352 |
+
out = _out_adj(edges)
|
| 353 |
+
# F3 — decision branches labeled, >= 2 outgoing
|
| 354 |
+
for n in nodes:
|
| 355 |
+
if n.get("role") == "decision":
|
| 356 |
+
outs = [e for e in edges if e["from"] == n["id"]]
|
| 357 |
+
if len(outs) < 2:
|
| 358 |
+
res.error("F3", f"decision node '{n['id']}' has {len(outs)} outgoing edges (need >= 2)")
|
| 359 |
+
for e in outs:
|
| 360 |
+
if not e.get("label", "").strip():
|
| 361 |
+
res.error("F3", f"decision node '{n['id']}' has an unlabeled branch to '{e['to']}'")
|
| 362 |
+
|
| 363 |
+
# F4 — reachability from the (first) start
|
| 364 |
+
if starts:
|
| 365 |
+
reachable = _bfs(starts[0]["id"], out)
|
| 366 |
+
for n in nodes:
|
| 367 |
+
if n["id"] not in reachable:
|
| 368 |
+
res.error("F4", f"node '{n['id']}' is not reachable from start '{starts[0]['id']}'")
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def _data_pipeline(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 372 |
+
out = _out_adj(edges)
|
| 373 |
+
indeg = _indegree(nodes, edges)
|
| 374 |
+
outdeg = _outdegree(nodes, edges)
|
| 375 |
+
|
| 376 |
+
# P1 — acyclic
|
| 377 |
+
cycle = _find_cycle(nodes, out)
|
| 378 |
+
if cycle:
|
| 379 |
+
res.error("P1", f"data_pipeline must be acyclic; cycle through {' -> '.join(cycle)}")
|
| 380 |
+
|
| 381 |
+
# P2 — has >=1 source and >=1 sink (by role)
|
| 382 |
+
sources = _roles(nodes, "source")
|
| 383 |
+
sinks = _roles(nodes, "sink")
|
| 384 |
+
if not sources:
|
| 385 |
+
res.error("P2", "data_pipeline needs at least one 'source' node")
|
| 386 |
+
if not sinks:
|
| 387 |
+
res.error("P2", "data_pipeline needs at least one 'sink' node")
|
| 388 |
+
|
| 389 |
+
# P3 — sources have no incoming, sinks have no outgoing
|
| 390 |
+
for n in sources:
|
| 391 |
+
if indeg.get(n["id"], 0) > 0:
|
| 392 |
+
res.error("P3", f"source '{n['id']}' has incoming edges")
|
| 393 |
+
for n in sinks:
|
| 394 |
+
if outdeg.get(n["id"], 0) > 0:
|
| 395 |
+
res.error("P3", f"sink '{n['id']}' has outgoing edges")
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _sequence(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 399 |
+
# S1 — all nodes are participants (or actors)
|
| 400 |
+
for n in nodes:
|
| 401 |
+
if n.get("role") not in ("participant", "actor"):
|
| 402 |
+
res.error("S1", f"sequence_diagram node '{n['id']}' has role '{n.get('role')}' (expected participant/actor)")
|
| 403 |
+
|
| 404 |
+
# S4 — return messages should point back toward an earlier sender (warn only).
|
| 405 |
+
prior_pairs = set()
|
| 406 |
+
for i, e in enumerate(raw_edges):
|
| 407 |
+
kind = (e.get("meta") or {}).get("kind")
|
| 408 |
+
if kind == "return":
|
| 409 |
+
if (e["to"], e["from"]) not in prior_pairs:
|
| 410 |
+
res.warn("S4", f"edges[{i}] is a 'return' to '{e['to']}' with no preceding message from it")
|
| 411 |
+
prior_pairs.add((e["from"], e["to"]))
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _er(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 415 |
+
# E1 — nodes are entities
|
| 416 |
+
for n in nodes:
|
| 417 |
+
if n.get("role") != "entity":
|
| 418 |
+
res.error("E1", f"er_diagram node '{n['id']}' has role '{n.get('role')}' (expected entity)")
|
| 419 |
+
# E2 — each entity declares >=1 field; at most one pk
|
| 420 |
+
for n in nodes:
|
| 421 |
+
fields = (n.get("meta") or {}).get("fields")
|
| 422 |
+
if not fields:
|
| 423 |
+
res.error("E2", f"entity '{n['id']}' declares no fields")
|
| 424 |
+
continue
|
| 425 |
+
pks = [f for f in fields if f.get("key") == "pk"]
|
| 426 |
+
if len(pks) > 1:
|
| 427 |
+
res.error("E2", f"entity '{n['id']}' has {len(pks)} primary keys (max 1)")
|
| 428 |
+
# E3 — relationships carry cardinality (also schema-enforced; restated)
|
| 429 |
+
for i, e in enumerate(raw_edges):
|
| 430 |
+
if not (e.get("meta") or {}).get("cardinality"):
|
| 431 |
+
res.error("E3", f"edges[{i}] ({e['from']}->{e['to']}) is missing meta.cardinality")
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _mind_map(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 435 |
+
roots = _roles(nodes, "root")
|
| 436 |
+
# M1 — exactly one root
|
| 437 |
+
if len(roots) != 1:
|
| 438 |
+
res.error("M1", f"mind_map needs exactly one 'root' node, found {len(roots)}")
|
| 439 |
+
|
| 440 |
+
n = len(nodes)
|
| 441 |
+
# M2 — tree shape: undirected connected & acyclic, every non-root has one parent.
|
| 442 |
+
if len(edges) != n - 1:
|
| 443 |
+
res.error("M2", f"mind_map is not a tree: {n} nodes need exactly {n-1} edges, found {len(edges)}")
|
| 444 |
+
undirected = _undirected_adj(edges)
|
| 445 |
+
if roots:
|
| 446 |
+
seen = _bfs(roots[0]["id"], undirected)
|
| 447 |
+
if len(seen) != n:
|
| 448 |
+
res.error("M2", f"mind_map is not connected: {len(seen)}/{n} nodes reachable from root (undirected)")
|
| 449 |
+
# directed parent check: root in-degree 0, others exactly 1
|
| 450 |
+
indeg = _indegree(nodes, edges)
|
| 451 |
+
for nd in nodes:
|
| 452 |
+
d = indeg.get(nd["id"], 0)
|
| 453 |
+
if nd["id"] == roots[0]["id"]:
|
| 454 |
+
if d != 0:
|
| 455 |
+
res.error("M2", f"root '{nd['id']}' has {d} incoming edges (expected 0)")
|
| 456 |
+
elif d != 1:
|
| 457 |
+
res.error("M2", f"node '{nd['id']}' has {d} parents (expected exactly 1)")
|
| 458 |
+
|
| 459 |
+
# M3 — root reaches all (directed)
|
| 460 |
+
out = _out_adj(edges)
|
| 461 |
+
reach = _bfs(roots[0]["id"], out)
|
| 462 |
+
for nd in nodes:
|
| 463 |
+
if nd["id"] not in reach:
|
| 464 |
+
res.error("M3", f"node '{nd['id']}' is not reachable from root (directed)")
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def _timeline(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 468 |
+
# T1 — every node has meta.date (schema-enforced; restated). T2 — sortable.
|
| 469 |
+
for n in nodes:
|
| 470 |
+
date = (n.get("meta") or {}).get("date")
|
| 471 |
+
if not date:
|
| 472 |
+
res.error("T1", f"timeline node '{n['id']}' is missing meta.date")
|
| 473 |
+
elif _date_sort_key(date) is None:
|
| 474 |
+
res.warn("T2", f"timeline node '{n['id']}' date {date!r} is not obviously sortable")
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _wireframe(nodes, edges, raw_edges, by_id, id_set, groups, res):
|
| 478 |
+
# W1 — every node has meta.kind (schema-enforced; restated)
|
| 479 |
+
for n in nodes:
|
| 480 |
+
if not (n.get("meta") or {}).get("kind"):
|
| 481 |
+
res.error("W1", f"wireframe node '{n['id']}' is missing meta.kind")
|
| 482 |
+
# W2 — every node belongs to a screen group
|
| 483 |
+
if not groups:
|
| 484 |
+
res.error("W2", "mobile_wireframe has no groups (screens)")
|
| 485 |
+
for n in nodes:
|
| 486 |
+
if not n.get("group"):
|
| 487 |
+
res.error("W2", f"wireframe node '{n['id']}' is not assigned to a screen group")
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _size_guards(nodes, edges, res):
|
| 491 |
+
# G1 / G3 — schema already bounds counts; these are quality warnings.
|
| 492 |
+
if len(nodes) > 25:
|
| 493 |
+
res.warn("G1", f"{len(nodes)} nodes — layout quality tends to degrade past ~25")
|
| 494 |
+
if nodes and len(edges) > len(nodes) * 3:
|
| 495 |
+
res.warn("G3", f"{len(edges)} edges for {len(nodes)} nodes (>3x) — likely too dense")
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
# --------------------------------------------------------------------------------------
|
| 499 |
+
# Graph helpers
|
| 500 |
+
# --------------------------------------------------------------------------------------
|
| 501 |
+
|
| 502 |
+
def _dups(seq):
|
| 503 |
+
seen, dups = set(), []
|
| 504 |
+
for x in seq:
|
| 505 |
+
if x in seen and x not in dups:
|
| 506 |
+
dups.append(x)
|
| 507 |
+
seen.add(x)
|
| 508 |
+
return dups
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def _out_adj(edges):
|
| 512 |
+
adj = {}
|
| 513 |
+
for e in edges:
|
| 514 |
+
adj.setdefault(e["from"], []).append(e["to"])
|
| 515 |
+
return adj
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def _undirected_adj(edges):
|
| 519 |
+
adj = {}
|
| 520 |
+
for e in edges:
|
| 521 |
+
adj.setdefault(e["from"], []).append(e["to"])
|
| 522 |
+
adj.setdefault(e["to"], []).append(e["from"])
|
| 523 |
+
return adj
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _indegree(nodes, edges):
|
| 527 |
+
deg = {n["id"]: 0 for n in nodes}
|
| 528 |
+
for e in edges:
|
| 529 |
+
deg[e["to"]] = deg.get(e["to"], 0) + 1
|
| 530 |
+
return deg
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def _outdegree(nodes, edges):
|
| 534 |
+
deg = {n["id"]: 0 for n in nodes}
|
| 535 |
+
for e in edges:
|
| 536 |
+
deg[e["from"]] = deg.get(e["from"], 0) + 1
|
| 537 |
+
return deg
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _bfs(start, adj):
|
| 541 |
+
seen, stack = set(), [start]
|
| 542 |
+
while stack:
|
| 543 |
+
cur = stack.pop()
|
| 544 |
+
if cur in seen:
|
| 545 |
+
continue
|
| 546 |
+
seen.add(cur)
|
| 547 |
+
for nxt in adj.get(cur, []):
|
| 548 |
+
if nxt not in seen:
|
| 549 |
+
stack.append(nxt)
|
| 550 |
+
return seen
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
def _find_cycle(nodes, adj):
|
| 554 |
+
"""Return a node sequence describing a directed cycle, or None. DFS with colors."""
|
| 555 |
+
WHITE, GRAY, BLACK = 0, 1, 2
|
| 556 |
+
color = {n["id"]: WHITE for n in nodes}
|
| 557 |
+
parent = {}
|
| 558 |
+
|
| 559 |
+
def visit(u):
|
| 560 |
+
color[u] = GRAY
|
| 561 |
+
for v in adj.get(u, []):
|
| 562 |
+
if v not in color: # endpoint outside node set; skip defensively
|
| 563 |
+
continue
|
| 564 |
+
if color[v] == WHITE:
|
| 565 |
+
parent[v] = u
|
| 566 |
+
r = visit(v)
|
| 567 |
+
if r:
|
| 568 |
+
return r
|
| 569 |
+
elif color[v] == GRAY: # back edge u->v closes a cycle
|
| 570 |
+
path = [u] # walk parents from u up to the ancestor v
|
| 571 |
+
x = u
|
| 572 |
+
while x != v and x in parent:
|
| 573 |
+
x = parent[x]
|
| 574 |
+
path.append(x)
|
| 575 |
+
path.reverse() # now v ... u
|
| 576 |
+
return path + [v] # close the loop: v ... u -> v
|
| 577 |
+
color[u] = BLACK
|
| 578 |
+
return None
|
| 579 |
+
|
| 580 |
+
sys.setrecursionlimit(10000)
|
| 581 |
+
for n in nodes:
|
| 582 |
+
if color[n["id"]] == WHITE:
|
| 583 |
+
r = visit(n["id"])
|
| 584 |
+
if r:
|
| 585 |
+
return r
|
| 586 |
+
return None
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
_DATE_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$")
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def _date_sort_key(date):
|
| 593 |
+
"""Return a sortable key for common date forms, or None if not obviously sortable.
|
| 594 |
+
Accepts ISO-ish (YYYY, YYYY-MM, YYYY-MM-DD) and 'Q<n> YYYY' style labels."""
|
| 595 |
+
date = date.strip()
|
| 596 |
+
if _DATE_RE.match(date):
|
| 597 |
+
return date
|
| 598 |
+
m = re.match(r"^Q([1-4])\s+(\d{4})$", date)
|
| 599 |
+
if m:
|
| 600 |
+
return f"{m.group(2)}-Q{m.group(1)}"
|
| 601 |
+
return None
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
# --------------------------------------------------------------------------------------
|
| 605 |
+
# Public API
|
| 606 |
+
# --------------------------------------------------------------------------------------
|
| 607 |
+
|
| 608 |
+
_SCHEMA_CACHE = None
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def load_schema(path=SCHEMA_FILE):
|
| 612 |
+
global _SCHEMA_CACHE
|
| 613 |
+
if _SCHEMA_CACHE is None:
|
| 614 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 615 |
+
_SCHEMA_CACHE = json.load(f)
|
| 616 |
+
return _SCHEMA_CACHE
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def validate_dsl(dsl, schema=None):
|
| 620 |
+
"""Validate a parsed DSL object. Returns a Result (res.ok == accepted)."""
|
| 621 |
+
res = Result()
|
| 622 |
+
schema = schema if schema is not None else load_schema()
|
| 623 |
+
|
| 624 |
+
# Layer 1 — structural. If it fails, semantic checks would just crash on the same
|
| 625 |
+
# malformed data, so we stop here and report the structural errors.
|
| 626 |
+
structural_errors = SchemaValidator(schema).validate(dsl)
|
| 627 |
+
if structural_errors:
|
| 628 |
+
for code, msg in structural_errors:
|
| 629 |
+
res.error(code, msg)
|
| 630 |
+
return res
|
| 631 |
+
|
| 632 |
+
# Layer 2 — semantic.
|
| 633 |
+
_semantic(dsl, res)
|
| 634 |
+
return res
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def validate_dsl_text(text, schema=None):
|
| 638 |
+
"""Validate DSL given as a JSON string (e.g. a model's assistant output)."""
|
| 639 |
+
res = Result()
|
| 640 |
+
try:
|
| 641 |
+
dsl = json.loads(text)
|
| 642 |
+
except json.JSONDecodeError as e:
|
| 643 |
+
res.error("json", f"not valid JSON: {e}")
|
| 644 |
+
return res
|
| 645 |
+
if not isinstance(dsl, dict):
|
| 646 |
+
res.error("json", f"top-level DSL must be an object, got {_pytype(dsl)}")
|
| 647 |
+
return res
|
| 648 |
+
return validate_dsl(dsl, schema)
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def _assistant_content(row):
|
| 652 |
+
"""Pull the assistant DSL string out of a chat-format training row."""
|
| 653 |
+
msgs = row.get("messages")
|
| 654 |
+
if not isinstance(msgs, list):
|
| 655 |
+
return None
|
| 656 |
+
for m in reversed(msgs):
|
| 657 |
+
if isinstance(m, dict) and m.get("role") == "assistant":
|
| 658 |
+
return m.get("content")
|
| 659 |
+
return None
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
# --------------------------------------------------------------------------------------
|
| 663 |
+
# CLI
|
| 664 |
+
# --------------------------------------------------------------------------------------
|
| 665 |
+
|
| 666 |
+
def _read(path):
|
| 667 |
+
if path == "-":
|
| 668 |
+
return sys.stdin.read()
|
| 669 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 670 |
+
return f.read()
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
def _run_single(path, show_warnings):
|
| 674 |
+
res = validate_dsl_text(_read(path))
|
| 675 |
+
status = "PASS" if res.ok else "FAIL"
|
| 676 |
+
print(f"{status}: {path}")
|
| 677 |
+
for code, msg in res.errors:
|
| 678 |
+
print(f" ERROR [{code}] {msg}")
|
| 679 |
+
if show_warnings:
|
| 680 |
+
for code, msg in res.warnings:
|
| 681 |
+
print(f" WARN [{code}] {msg}")
|
| 682 |
+
return 0 if res.ok else 1
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def _run_jsonl(path, show_warnings, max_show):
|
| 686 |
+
text = _read(path)
|
| 687 |
+
total = passed = failed = warned = 0
|
| 688 |
+
shown = 0
|
| 689 |
+
error_codes = {}
|
| 690 |
+
for lineno, line in enumerate(text.splitlines(), 1):
|
| 691 |
+
line = line.strip()
|
| 692 |
+
if not line:
|
| 693 |
+
continue
|
| 694 |
+
total += 1
|
| 695 |
+
try:
|
| 696 |
+
row = json.loads(line)
|
| 697 |
+
except json.JSONDecodeError as e:
|
| 698 |
+
failed += 1
|
| 699 |
+
error_codes["json"] = error_codes.get("json", 0) + 1
|
| 700 |
+
if shown < max_show:
|
| 701 |
+
print(f"FAIL line {lineno}: row is not valid JSON: {e}")
|
| 702 |
+
shown += 1
|
| 703 |
+
continue
|
| 704 |
+
|
| 705 |
+
content = _assistant_content(row) if isinstance(row, dict) else None
|
| 706 |
+
# Accept either a chat row or a bare DSL object on the line.
|
| 707 |
+
res = validate_dsl_text(content) if content is not None else validate_dsl(row) \
|
| 708 |
+
if isinstance(row, dict) else Result()
|
| 709 |
+
if content is None and not isinstance(row, dict):
|
| 710 |
+
res = Result()
|
| 711 |
+
res.error("json", "line is neither a chat row nor a DSL object")
|
| 712 |
+
|
| 713 |
+
if res.warnings:
|
| 714 |
+
warned += 1
|
| 715 |
+
for code, _ in res.errors:
|
| 716 |
+
error_codes[code] = error_codes.get(code, 0) + 1
|
| 717 |
+
|
| 718 |
+
if res.ok:
|
| 719 |
+
passed += 1
|
| 720 |
+
if show_warnings and res.warnings and shown < max_show:
|
| 721 |
+
print(f"WARN line {lineno}:")
|
| 722 |
+
for code, msg in res.warnings:
|
| 723 |
+
print(f" WARN [{code}] {msg}")
|
| 724 |
+
shown += 1
|
| 725 |
+
else:
|
| 726 |
+
failed += 1
|
| 727 |
+
if shown < max_show:
|
| 728 |
+
print(f"FAIL line {lineno}:")
|
| 729 |
+
for code, msg in res.errors:
|
| 730 |
+
print(f" ERROR [{code}] {msg}")
|
| 731 |
+
shown += 1
|
| 732 |
+
|
| 733 |
+
print("-" * 60)
|
| 734 |
+
print(f"total={total} passed={passed} failed={failed} with_warnings={warned}")
|
| 735 |
+
if error_codes:
|
| 736 |
+
breakdown = " ".join(f"{c}={n}" for c, n in sorted(error_codes.items(), key=lambda x: -x[1]))
|
| 737 |
+
print(f"error breakdown: {breakdown}")
|
| 738 |
+
return 0 if failed == 0 else 1
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
def main(argv=None):
|
| 742 |
+
ap = argparse.ArgumentParser(description="Validate Diagram DSL (structural + semantic gate).")
|
| 743 |
+
ap.add_argument("input", help="DSL JSON file, JSONL dataset, or '-' for stdin")
|
| 744 |
+
ap.add_argument("--jsonl", action="store_true",
|
| 745 |
+
help="treat input as a JSONL dataset; validate each row's assistant DSL")
|
| 746 |
+
ap.add_argument("--warnings", action="store_true", help="also show warnings")
|
| 747 |
+
ap.add_argument("--max-show", type=int, default=20,
|
| 748 |
+
help="max failing/warning rows to print in --jsonl mode (default 20)")
|
| 749 |
+
args = ap.parse_args(argv)
|
| 750 |
+
|
| 751 |
+
if args.jsonl:
|
| 752 |
+
return _run_jsonl(args.input, args.warnings, args.max_show)
|
| 753 |
+
return _run_single(args.input, args.warnings)
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
if __name__ == "__main__":
|
| 757 |
+
sys.exit(main())
|