"""ActivityDefinition markdown file parser (T44). Scans activity-definitions/*.md in the local repo and any directories listed in ACTIVITY_DEFINITION_DIRS (colon-separated). Returns parsed ActivityDefinitionDef objects; raises ParseError on malformed input. Never silently ignores a broken definition file. """ from __future__ import annotations import os import re from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml from activity_core.bounded_operations import validate_bounded_operations from activity_core.glas_profile import ( ProfileRefError, require_harness_profile, validate_profile_ref, ) class ParseError(Exception): """Raised when a definition file cannot be parsed.""" def __init__(self, file: Path, line: int | None, message: str) -> None: self.file = file self.line = line self.message = message loc = f"{file}:{line}" if line else str(file) super().__init__(f"{loc}: {message}") @dataclass class ActivityDefinitionDef: """Parsed in-memory representation of an activity-definitions/*.md file.""" id: str name: str enabled: bool trigger_config: dict[str, Any] context_sources: list[dict[str, Any]] rules: list[dict[str, Any]] instructions: list[dict[str, Any]] governance: str owner: str status: str source_file: Path _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) _FENCED_BLOCK_RE = re.compile(r"^```(\w+)\s*\n(.*?)\n```", re.DOTALL | re.MULTILINE) def _instruction_can_emit_tasks(instruction: dict[str, Any]) -> bool: """Return false only for the explicit deterministic report-only shape.""" model = str(instruction.get("model") or "").strip().lower() return not ( model in {"none", "deterministic", "unused"} and bool(instruction.get("report_sinks")) ) def _validate_execution_declarations( rules: list[dict[str, Any]], instructions: list[dict[str, Any]], file: Path, ) -> None: """Validate declared profile structure without mirroring the Glas catalog.""" profile_required = require_harness_profile() declarations: list[tuple[str, dict[str, Any], bool]] = [] for rule in rules: action = rule.get("action") if not isinstance(action, dict): raise ParseError( file, None, f"rule {rule.get('id')!r} action must be a YAML mapping", ) declarations.append((f"rule {rule.get('id')!r}", action, True)) for instruction in instructions: declarations.append( ( f"instruction {instruction.get('id')!r}", instruction, _instruction_can_emit_tasks(instruction), ) ) for location, declaration, can_emit_tasks in declarations: raw_profile = declaration.get("harness_profile_ref") if raw_profile is None: if profile_required and can_emit_tasks: raise ParseError( file, None, f"{location} must declare harness_profile_ref while " "ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE is enabled", ) else: try: declaration["harness_profile_ref"] = validate_profile_ref(raw_profile) except ProfileRefError as exc: raise ParseError( file, None, f"{location} has invalid harness_profile_ref: {exc}", ) from exc execution_refs = declaration.get("execution_refs") if execution_refs is not None and not isinstance(execution_refs, dict): raise ParseError( file, None, f"{location} execution_refs must be a YAML mapping", ) def _normalise_review_advisory( instructions: list[dict[str, Any]], file: Path ) -> None: """Migrate the legacy gate-sounding field to advisory-only semantics.""" for instruction in instructions: legacy_present = "review_required" in instruction advisory_present = "review_advisory" in instruction if legacy_present and advisory_present and ( bool(instruction["review_required"]) != bool(instruction["review_advisory"]) ): raise ParseError( file, None, f"instruction {instruction.get('id')!r} declares conflicting " "review_required and review_advisory values", ) if legacy_present: instruction.setdefault("review_advisory", bool(instruction["review_required"])) instruction.pop("review_required", None) def _scan_dirs() -> list[Path]: dirs: list[Path] = [] default_dir = Path("activity-definitions") if default_dir.is_dir(): dirs.append(default_dir) extra = os.environ.get("ACTIVITY_DEFINITION_DIRS", "") for part in extra.split(":"): part = part.strip() if not part: continue p = Path(part) / "activity-definitions" if p.is_dir(): dirs.append(p) return dirs def _parse_trigger(trigger_dict: dict, file: Path) -> dict[str, Any]: """Normalise the trigger section into a trigger_config dict.""" trigger_type = trigger_dict.get("type") if trigger_type == "cron": return { "trigger_type": "cron", "cron_expression": trigger_dict.get("cron_expression", ""), "timezone": trigger_dict.get("timezone", "UTC"), "jitter_seconds": trigger_dict.get("jitter_seconds", 0), "misfire_policy": trigger_dict.get("misfire_policy", "skip"), } elif trigger_type == "event": return { "trigger_type": "event", "event_type": trigger_dict.get("event_type", ""), "filters": trigger_dict.get("filters", {}), } elif trigger_type == "scheduled": at = trigger_dict.get("at") if at is None: raise ParseError(file, None, "trigger.at is required for type=scheduled") at_str = at.isoformat() if hasattr(at, "isoformat") else str(at) return { "trigger_type": "scheduled", "at": at_str, "timezone": trigger_dict.get("timezone", "UTC"), } else: raise ParseError(file, None, f"unknown trigger type {trigger_type!r}") def parse_file(path: Path) -> ActivityDefinitionDef: """Parse one ActivityDefinition markdown file. Raises ParseError on malformed or missing required fields. """ try: text = path.read_text(encoding="utf-8") except OSError as exc: raise ParseError(path, None, f"cannot read file: {exc}") from exc fm_match = _FRONTMATTER_RE.match(text) if not fm_match: raise ParseError(path, 1, "missing or malformed YAML frontmatter") try: fm = yaml.safe_load(fm_match.group(1)) except yaml.YAMLError as exc: raise ParseError(path, None, f"YAML parse error in frontmatter: {exc}") from exc if not isinstance(fm, dict): raise ParseError(path, 1, "frontmatter must be a YAML mapping") for req in ("id", "name", "trigger"): if req not in fm: raise ParseError(path, None, f"missing required frontmatter field: {req!r}") trigger_section = fm["trigger"] if not isinstance(trigger_section, dict): raise ParseError(path, None, "trigger must be a YAML mapping") trigger_config = _parse_trigger(trigger_section, path) raw_sources = fm.get("context_sources", []) or [] context_sources: list[dict[str, Any]] = [] for cs in raw_sources: if not isinstance(cs, dict): raise ParseError(path, None, "each context_source must be a mapping") context_sources.append(cs) # Parse fenced rule/instruction blocks from the body body = text[fm_match.end():] rules: list[dict[str, Any]] = [] instructions: list[dict[str, Any]] = [] for block_match in _FENCED_BLOCK_RE.finditer(body): lang = block_match.group(1).strip() block_body = block_match.group(2) try: block_data = yaml.safe_load(block_body) except yaml.YAMLError as exc: raise ParseError(path, None, f"YAML parse error in {lang!r} block: {exc}") from exc if lang == "rule": if not isinstance(block_data, dict): raise ParseError(path, None, "rule block must be a YAML mapping") if "id" not in block_data: raise ParseError(path, None, "rule block missing required field 'id'") if "action" not in block_data: raise ParseError(path, None, f"rule {block_data['id']!r} missing 'action'") rules.append(block_data) elif lang == "instruction": if not isinstance(block_data, dict): raise ParseError(path, None, "instruction block must be a YAML mapping") if "id" not in block_data: raise ParseError(path, None, "instruction block missing required field 'id'") instructions.append(block_data) _normalise_review_advisory(instructions, path) _validate_execution_declarations(rules, instructions, path) try: validate_bounded_operations(context_sources, instructions) except ValueError as exc: raise ParseError(path, None, str(exc)) from exc return ActivityDefinitionDef( id=str(fm["id"]), name=str(fm["name"]), enabled=bool(fm.get("enabled", True)), trigger_config=trigger_config, context_sources=context_sources, rules=rules, instructions=instructions, governance=str(fm.get("governance", "publisher-declared")), owner=str(fm.get("owner", "")), status=str(fm.get("status", "active")), source_file=path, ) def scan_and_parse() -> list[ActivityDefinitionDef]: """Scan all definition directories and parse all .md files. Raises ParseError on any broken file — never silently skips. """ dirs = _scan_dirs() defs: list[ActivityDefinitionDef] = [] for d in dirs: for path in sorted(d.glob("*.md")): defs.append(parse_file(path)) return defs