Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
94 lines
2.9 KiB
Python
94 lines
2.9 KiB
Python
"""
|
|
Validation for ResolutionFlow tree markdown.
|
|
|
|
Validates markdown without saving, returning detailed errors with line numbers.
|
|
"""
|
|
from app.services.tree_markdown_parser import parse_markdown_to_tree, ParseError
|
|
|
|
|
|
def validate_tree_markdown(markdown: str) -> list[ParseError]:
|
|
"""Validate tree markdown and return all errors/warnings.
|
|
|
|
This wraps the parser and adds additional semantic checks.
|
|
|
|
Args:
|
|
markdown: The markdown string to validate.
|
|
|
|
Returns:
|
|
List of ParseError objects with line, column, message, severity.
|
|
"""
|
|
result = parse_markdown_to_tree(markdown)
|
|
errors = list(result.errors)
|
|
|
|
# If parsing completely failed, return early
|
|
if result.tree_structure is None:
|
|
return errors
|
|
|
|
# Additional semantic validation on the parsed tree
|
|
_validate_tree_semantics(result.tree_structure, errors)
|
|
|
|
return errors
|
|
|
|
|
|
def _validate_tree_semantics(tree: dict, errors: list[ParseError]) -> None:
|
|
"""Run semantic checks on a parsed tree structure."""
|
|
all_ids: set[str] = set()
|
|
has_solution = False
|
|
|
|
def _collect_ids(node: dict) -> None:
|
|
nonlocal has_solution
|
|
all_ids.add(node.get("id", ""))
|
|
if node.get("type") == "solution":
|
|
has_solution = True
|
|
for child in node.get("children", []):
|
|
_collect_ids(child)
|
|
|
|
_collect_ids(tree)
|
|
|
|
if not has_solution:
|
|
errors.append(ParseError(
|
|
line=1, column=1,
|
|
message="Tree must have at least one solution node",
|
|
severity="warning"
|
|
))
|
|
|
|
# Check for empty required fields
|
|
def _validate_node(node: dict) -> None:
|
|
ntype = node.get("type", "")
|
|
nid = node.get("id", "")
|
|
|
|
if ntype == "decision":
|
|
if not node.get("question", "").strip():
|
|
errors.append(ParseError(
|
|
line=1, column=1,
|
|
message=f"Decision node '{nid}' has an empty question",
|
|
severity="warning"
|
|
))
|
|
if not node.get("options"):
|
|
errors.append(ParseError(
|
|
line=1, column=1,
|
|
message=f"Decision node '{nid}' has no options",
|
|
severity="warning"
|
|
))
|
|
|
|
elif ntype == "action":
|
|
if not node.get("title", "").strip():
|
|
errors.append(ParseError(
|
|
line=1, column=1,
|
|
message=f"Action node '{nid}' has an empty title",
|
|
severity="warning"
|
|
))
|
|
|
|
elif ntype == "solution":
|
|
if not node.get("title", "").strip():
|
|
errors.append(ParseError(
|
|
line=1, column=1,
|
|
message=f"Solution node '{nid}' has an empty title",
|
|
severity="warning"
|
|
))
|
|
|
|
for child in node.get("children", []):
|
|
_validate_node(child)
|
|
|
|
_validate_node(tree)
|