#!/usr/bin/env python3 """Reference parser for .contract.md files (v1.0, v2.0, and v2.1). Extracts all structured fields from a contract file without using an LLM. Proves the format is machine-parseable by a simple tool. Usage: python contract_parser.py python contract_parser.py --json # machine output python contract_parser.py --validate # check required fields python contract_parser.py --list # show function signatures only python contract_parser.py --dir # parse all .contract.md files python contract_parser.py --dir --validate # batch validate """ from __future__ import annotations import json import re import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml # ── Data classes ────────────────────────────────────────────────────────────── @dataclass class ErrorSpec: error_type: str recovery: str @dataclass class Precondition: id: str severity: str # "hard" | "soft" condition: str validation: str = "" @dataclass class Postcondition: id: str category: str # "return_value" | "state_change" | "side_effect" | "exception" condition: str validation: str = "" @dataclass class TestSpec: name: str category: str # "happy" | "error" | "boundary" | "edge" | "security" description: str # full text after category tags: tuple[str, ...] = () # modifiers: ("tracer",) etc. @dataclass class Step: number: int text: str step_type: str = "" # "setup" | "sequential" | "branch" | "loop" | "error_handler" | "cleanup" sub_steps: list[str] = field(default_factory=list) branch_if: str | None = None branch_else: list[str] = field(default_factory=list) @dataclass class FunctionBlock: name: str params: str return_type: str brief: str requires: str | None = None # v1 single-line REQUIRES ensures: str | None = None # v1 single-line ENSURES preconditions: list[Precondition] = field(default_factory=list) # v2 postconditions: list[Postcondition] = field(default_factory=list) # v2 errors: list[ErrorSpec] = field(default_factory=list) state_transitions: list[str] = field(default_factory=list) steps: list[Step] = field(default_factory=list) tests: list[TestSpec] = field(default_factory=list) # v2 @dataclass class Contract: frontmatter: dict[str, Any] body_sections: dict[str, str] functions: list[FunctionBlock] source_path: Path | None = None @property def version(self) -> str: return str(self.frontmatter.get("contract_version", "1.0")) # ── Validation ──────────────────────────────────────────────────────────────── REQUIRED_FRONTMATTER_V1 = [ "contract_version", "module", "purpose", "language", "min_complexity", ] REQUIRED_FRONTMATTER_V2_MODULE = [ "contract_version", "module", "purpose", "language", "complexity", ] REQUIRED_FRONTMATTER_V2_ISSUE = [ "contract_version", "target_module", "scope", "language", "complexity", "prd", ] RECOMMENDED_FRONTMATTER_V1 = ["depends_on", "used_by", "estimated_loc"] RECOMMENDED_FRONTMATTER_V2 = ["depends_on", "used_by", "estimated_loc", "confidence"] REQUIRED_BODY_SECTIONS = ["Context", "Data flow", "Invariants"] VALID_COMPLEXITIES_V1 = {"trivial", "simple", "medium", "complex", "expert"} VALID_COMPLEXITIES_V2 = {"low", "medium", "high"} VALID_PRE_SEVERITIES = {"hard", "soft"} VALID_POST_CATEGORIES = {"return_value", "state_change", "side_effect", "exception"} VALID_STEP_TYPES = {"setup", "sequential", "branch", "loop", "error_handler", "cleanup"} VALID_TEST_CATEGORIES_V20 = {"happy", "error", "boundary", "edge", "security"} VALID_TEST_CATEGORIES_V21 = VALID_TEST_CATEGORIES_V20 | {"scenario", "trace", "adversarial", "property"} _ISSUE_PATH_RE = re.compile(r"docs/contracts/issues/\d+\.contract\.md$") def _is_issue_scoped(contract: Contract) -> bool: """Detect issue-scoped contracts per CONTRACT-FORMAT § 2.1.I.""" if contract.source_path and _ISSUE_PATH_RE.search(str(contract.source_path)): return True return "prd" in contract.frontmatter @dataclass class ValidationIssue: severity: str # "error" | "warning" message: str def validate_contract(contract: Contract) -> list[ValidationIssue]: """Check a parsed contract for required fields and structural issues.""" issues: list[ValidationIssue] = [] fm = contract.frontmatter is_v2 = contract.version.startswith("2") is_v21 = contract.version.startswith("2.1") issue_scoped = is_v2 and _is_issue_scoped(contract) if is_v2: required_fm = REQUIRED_FRONTMATTER_V2_ISSUE if issue_scoped else REQUIRED_FRONTMATTER_V2_MODULE else: required_fm = REQUIRED_FRONTMATTER_V1 recommended_fm = RECOMMENDED_FRONTMATTER_V2 if is_v2 else RECOMMENDED_FRONTMATTER_V1 valid_complexities = VALID_COMPLEXITIES_V2 if is_v2 else VALID_COMPLEXITIES_V1 complexity_key = "complexity" if is_v2 else "min_complexity" # Required frontmatter for key in required_fm: if key not in fm or fm[key] is None: issues.append(ValidationIssue("error", f"Missing required frontmatter field: {key!r}")) # Complexity value complexity = fm.get(complexity_key, "") if complexity and complexity not in valid_complexities: issues.append(ValidationIssue( "error", f"{complexity_key} {complexity!r} is not one of: {sorted(valid_complexities)}" )) # Confidence range (v2) if is_v2 and "confidence" in fm: conf = fm["confidence"] if isinstance(conf, (int, float)) and not (0.0 <= conf <= 1.0): issues.append(ValidationIssue("error", f"confidence {conf} out of range [0.0, 1.0]")) # Recommended frontmatter for key in recommended_fm: if key not in fm: issues.append(ValidationIssue("warning", f"Missing recommended frontmatter field: {key!r}")) # Required body sections for section in REQUIRED_BODY_SECTIONS: if section not in contract.body_sections: issues.append(ValidationIssue("warning", f"Missing recommended body section: {section!r}")) # Function block count n = len(contract.functions) if n == 0: issues.append(ValidationIssue("warning", "No function blocks found")) elif n > 8: issues.append(ValidationIssue("warning", f"{n} function blocks — consider splitting the module (spec recommends 3–8)")) # Per-function checks for fn in contract.functions: prefix = f"Function {fn.name!r}" if not fn.brief: issues.append(ValidationIssue("error", f"{prefix}: missing BRIEF")) if not fn.steps: issues.append(ValidationIssue("error", f"{prefix}: missing STEPS")) if is_v2: # v2: validate preconditions for pre in fn.preconditions: if pre.severity not in VALID_PRE_SEVERITIES: issues.append(ValidationIssue( "warning", f"{prefix}: PRE {pre.id} severity {pre.severity!r} not in {sorted(VALID_PRE_SEVERITIES)}" )) # v2: validate postconditions for post in fn.postconditions: if post.category not in VALID_POST_CATEGORIES: issues.append(ValidationIssue( "warning", f"{prefix}: POST {post.id} category {post.category!r} not in {sorted(VALID_POST_CATEGORIES)}" )) # v2: validate step types for step in fn.steps: if step.step_type and step.step_type not in VALID_STEP_TYPES: issues.append(ValidationIssue( "warning", f"{prefix}: step {step.number} type {step.step_type!r} not in {sorted(VALID_STEP_TYPES)}" )) # v2: validate test categories valid_test_cats = VALID_TEST_CATEGORIES_V21 if is_v21 else VALID_TEST_CATEGORIES_V20 for test in fn.tests: if test.category not in valid_test_cats: issues.append(ValidationIssue( "warning", f"{prefix}: test {test.name!r} category {test.category!r} not in {sorted(valid_test_cats)}" )) # Modifier tags (e.g. "tracer") get the same vocabulary check. for tag in test.tags: if tag not in _VALID_TEST_TAGS: issues.append(ValidationIssue( "warning", f"{prefix}: test {test.name!r} tag {tag!r} not in {sorted(_VALID_TEST_TAGS)}", )) # v2: at most one tracer per function block — pairs with the tdd skill tracer_tests = [t for t in fn.tests if "tracer" in t.tags] if len(tracer_tests) > 1: names = ", ".join(repr(t.name) for t in tracer_tests) issues.append(ValidationIssue( "warning", f"{prefix}: multiple tracer tests ({names}) — only one test should carry the [tracer] tag", )) # v2: warn if no preconditions if not fn.preconditions and not fn.requires: issues.append(ValidationIssue("warning", f"{prefix}: no preconditions (PRE lines)")) # v2: warn if no postconditions if not fn.postconditions and not fn.ensures: issues.append(ValidationIssue("warning", f"{prefix}: no postconditions (POST lines)")) return issues # ── Parsing ──────────────────────────────────────────────────────────────────── def _parse_frontmatter(text: str) -> tuple[dict, str]: """Extract YAML frontmatter. Returns (metadata_dict, remaining_text).""" if not text.startswith("---"): raise ValueError("Contract must start with YAML frontmatter (---)") end = text.find("---", 3) if end == -1: raise ValueError("Unterminated frontmatter: no closing ---") meta = yaml.safe_load(text[3:end]) body = text[end + 3:].strip() return meta, body def _parse_body_sections(body: str) -> dict[str, str]: """Split body into named sections by ## headers.""" sections: dict[str, str] = {} current = None lines: list[str] = [] for line in body.split("\n"): if line.startswith("## "): if current is not None: sections[current] = "\n".join(lines).strip() current = line[3:].strip() lines = [] elif current is not None: lines.append(line) if current is not None: sections[current] = "\n".join(lines).strip() return sections def _extract_function_blocks(body: str) -> list[str]: """Extract raw function block text from ```contract fences.""" pattern = r"```contract\s*\n(.*?)```" return re.findall(pattern, body, re.DOTALL) _PRE_RE = re.compile(r"^\[(\S+)\s+(\S+)\]\s+(.*?)(?:\s+--\s+(.*))?$") _POST_RE = re.compile(r"^\[(\S+)\s+(\S+)\]\s+(.*?)(?:\s+--\s+(.*))?$") _TEST_RE = re.compile(r"^(\S+)\s+\[([\w,\s]+)\]:\s+(.*)$") _VALID_TEST_TAGS = {"tracer"} _STEP_TYPE_RE = re.compile(r"^\[(\w+)\]\s+(.*)") def _parse_function_block(text: str) -> FunctionBlock: """Parse a single function block into structured fields.""" lines = text.strip().split("\n") # Collect FN signature lines until we find one containing ") ->" fn_lines: list[str] = [] body_start = 0 for i, line in enumerate(lines): fn_lines.append(line) if ")" in line and "->" in line: body_start = i + 1 break fn_text = " ".join(l.strip() for l in fn_lines) fn_match = re.match(r"FN\s+([\w.]+)\((.*)\)\s*->\s*(.+)", fn_text) if not fn_match: raise ValueError(f"Invalid FN signature: {fn_text!r}") name = fn_match.group(1) params = fn_match.group(2).strip() return_type = fn_match.group(3).strip() brief = "" requires = None ensures = None preconditions: list[Precondition] = [] postconditions: list[Postcondition] = [] errors: list[ErrorSpec] = [] state_transitions: list[str] = [] steps: list[Step] = [] tests: list[TestSpec] = [] in_errors = False in_steps = False in_tests = False in_else = False current_step: Step | None = None for line in lines[body_start:]: stripped = line.strip() if stripped.startswith("BRIEF:"): brief = stripped[len("BRIEF:"):].strip() in_errors = in_steps = in_tests = False continue if stripped.startswith("REQUIRES:"): requires = stripped[len("REQUIRES:"):].strip() in_errors = in_steps = in_tests = False continue if stripped.startswith("ENSURES:"): ensures = stripped[len("ENSURES:"):].strip() in_errors = in_steps = in_tests = False continue if stripped.startswith("PRE:"): in_errors = in_steps = in_tests = False rest = stripped[len("PRE:"):].strip() m = _PRE_RE.match(rest) if m: preconditions.append(Precondition( id=m.group(1), severity=m.group(2), condition=m.group(3).strip(), validation=(m.group(4) or "").strip(), )) else: preconditions.append(Precondition(id="", severity="hard", condition=rest)) continue if stripped.startswith("POST:"): in_errors = in_steps = in_tests = False rest = stripped[len("POST:"):].strip() m = _POST_RE.match(rest) if m: postconditions.append(Postcondition( id=m.group(1), category=m.group(2), condition=m.group(3).strip(), validation=(m.group(4) or "").strip(), )) else: postconditions.append(Postcondition(id="", category="return_value", condition=rest)) continue if stripped.startswith("STATE:"): state_transitions.append(stripped[len("STATE:"):].strip()) in_errors = in_steps = in_tests = False continue if stripped == "STEPS:": in_steps = True in_errors = in_tests = False continue if stripped == "ERRORS:": in_errors = True in_steps = in_tests = False continue if stripped == "TESTS:": in_tests = True in_errors = in_steps = False # Flush last step if current_step is not None: steps.append(current_step) current_step = None continue if in_errors and "->" in stripped: parts = stripped.split("->", 1) errors.append(ErrorSpec( error_type=parts[0].strip(), recovery=parts[1].strip(), )) continue if in_tests and stripped: m = _TEST_RE.match(stripped) if m: # Bracket may hold "happy" or "happy,tracer". First token is # the category; remaining tokens are modifier tags. bracket_tokens = [t.strip() for t in m.group(2).split(",") if t.strip()] category = bracket_tokens[0] if bracket_tokens else "" tags = tuple(bracket_tokens[1:]) tests.append(TestSpec( name=m.group(1), category=category, description=m.group(3).strip(), tags=tags, )) continue # Numbered step step_match = re.match(r"(\d+)\.\s+(.*)", stripped) if step_match and in_steps: if current_step is not None: steps.append(current_step) step_text = step_match.group(2).strip() step_type = "" type_m = _STEP_TYPE_RE.match(step_text) if type_m: step_type = type_m.group(1) step_text = type_m.group(2).strip() current_step = Step( number=int(step_match.group(1)), text=step_text, step_type=step_type, ) in_else = False continue # Sub-step or branch under a step if in_steps and current_step is not None and stripped: if re.match(r"IF\s+", stripped) or re.match(r"ELSE IF\s+", stripped): current_step.branch_if = stripped in_else = False elif stripped == "ELSE:" or stripped.startswith("ELSE "): in_else = True elif stripped.startswith("- "): content = stripped[2:] if in_else: current_step.branch_else.append(content) else: current_step.sub_steps.append(content) elif re.match(r"[A-Z]", stripped): if in_else: current_step.branch_else.append(stripped) else: current_step.sub_steps.append(stripped) elif re.match(r"[a-z]", stripped): if in_else: current_step.branch_else.append(stripped) else: current_step.sub_steps.append(stripped) if current_step is not None: steps.append(current_step) return FunctionBlock( name=name, params=params, return_type=return_type, brief=brief, requires=requires, ensures=ensures, preconditions=preconditions, postconditions=postconditions, errors=errors, state_transitions=state_transitions, steps=steps, tests=tests, ) def parse_contract(path: Path) -> Contract: """Parse a .contract.md file into a structured Contract object.""" text = path.read_text(encoding="utf-8") frontmatter, body = _parse_frontmatter(text) body_sections = _parse_body_sections(body) raw_blocks = _extract_function_blocks(body) functions: list[FunctionBlock] = [] for b in raw_blocks: try: functions.append(_parse_function_block(b)) except ValueError: # Skip blocks that don't match FN signature syntax # (e.g. DATACLASS blocks, non-standard formats) pass return Contract( frontmatter=frontmatter, body_sections=body_sections, functions=functions, source_path=path, ) # ── Output ───────────────────────────────────────────────────────────────────── def _to_dict(contract: Contract) -> dict[str, Any]: """Convert to JSON-serialisable dict.""" return { "frontmatter": contract.frontmatter, "body_sections": contract.body_sections, "functions": [ { "name": fn.name, "params": fn.params, "return_type": fn.return_type, "brief": fn.brief, "requires": fn.requires, "ensures": fn.ensures, "preconditions": [ {"id": p.id, "severity": p.severity, "condition": p.condition, "validation": p.validation} for p in fn.preconditions ], "postconditions": [ {"id": p.id, "category": p.category, "condition": p.condition, "validation": p.validation} for p in fn.postconditions ], "errors": [ {"error_type": e.error_type, "recovery": e.recovery} for e in fn.errors ], "state_transitions": fn.state_transitions, "steps": [ { "number": s.number, "text": s.text, "step_type": s.step_type, "sub_steps": s.sub_steps, "branch_if": s.branch_if, "branch_else": s.branch_else, } for s in fn.steps ], "tests": [ {"name": t.name, "category": t.category, "description": t.description} for t in fn.tests ], } for fn in contract.functions ], } def print_human(contract: Contract) -> None: """Pretty-print contract summary for human reading.""" fm = contract.frontmatter is_v2 = contract.version.startswith("2") complexity_key = "complexity" if is_v2 else "min_complexity" issue_scoped = is_v2 and _is_issue_scoped(contract) if issue_scoped: print(f"Target: {fm.get('target_module', '?')}") print(f"Scope: {fm.get('scope', '?')}") else: print(f"Module: {fm.get('module', '?')}") print(f"Purpose: {fm.get('purpose', '?')}") print(f"Version: {contract.version}") print(f"Complexity: {fm.get(complexity_key, '?')}") print(f"Est. LOC: {fm.get('estimated_loc', '?')}") if is_v2 and "confidence" in fm: print(f"Confidence: {fm['confidence']}") print(f"Depends: {', '.join(fm.get('depends_on', []))}") print(f"Used by: {', '.join(fm.get('used_by', []))}") if is_v2 and fm.get("assumptions"): print(f"Assumptions: {len(fm['assumptions'])}") if is_v2 and fm.get("open_questions"): print(f"Open Qs: {len(fm['open_questions'])}") print() for name, text in contract.body_sections.items(): print(f"## {name}") preview = text[:300] + ("..." if len(text) > 300 else "") print(preview) print() print(f"Function blocks: {len(contract.functions)}") for fn in contract.functions: print(f"\n FN {fn.name}({fn.params}) -> {fn.return_type}") print(f" BRIEF: {fn.brief}") if fn.requires: print(f" REQUIRES: {fn.requires}") if fn.ensures: print(f" ENSURES: {fn.ensures}") for pre in fn.preconditions: print(f" PRE [{pre.id} {pre.severity}]: {pre.condition}") for post in fn.postconditions: print(f" POST [{post.id} {post.category}]: {post.condition}") if fn.errors: print(f" ERRORS ({len(fn.errors)}):") for e in fn.errors: print(f" {e.error_type} -> {e.recovery}") typed_steps = sum(1 for s in fn.steps if s.step_type) print(f" STEPS: {len(fn.steps)} ({typed_steps} typed)") if fn.tests: print(f" TESTS: {len(fn.tests)}") for t in fn.tests: print(f" [{t.category}] {t.name}: {t.description[:60]}") def print_list(contract: Contract) -> None: """Print function signatures only — one per line.""" fm = contract.frontmatter is_v2 = contract.version.startswith("2") complexity_key = "complexity" if is_v2 else "min_complexity" issue_scoped = is_v2 and _is_issue_scoped(contract) label = fm.get('target_module', '?') if issue_scoped else fm.get('module', '?') print(f"{label} [{fm.get(complexity_key, '?')}]") for fn in contract.functions: params_short = fn.params[:60] + ("..." if len(fn.params) > 60 else "") print(f" FN {fn.name}({params_short}) -> {fn.return_type}") print(f" {fn.brief}") def print_validation(contract: Contract, issues: list[ValidationIssue]) -> None: """Print validation results.""" path_str = str(contract.source_path) if contract.source_path else "?" errors = [i for i in issues if i.severity == "error"] if not issues: print(f"\u2713 {path_str} \u2014 OK") return status = "FAIL" if errors else "WARN" marker = "\u2717" if errors else "!" print(f"{marker} {path_str} \u2014 {status}") for issue in issues: marker = " ERROR " if issue.severity == "error" else " warn " print(f"{marker}{issue.message}") # ── CLI ──────────────────────────────────────────────────────────────────────── def main() -> None: if len(sys.argv) < 2: print("Usage: contract_parser.py [--json|--validate|--list] ") print(" contract_parser.py --dir [--json|--validate|--list]") sys.exit(1) args = sys.argv[1:] json_output = "--json" in args validate_mode = "--validate" in args list_mode = "--list" in args dir_mode = "--dir" in args flags = {"--json", "--validate", "--list", "--dir"} positional = [a for a in args if a not in flags] if dir_mode: if not positional: print("--dir requires a directory path") sys.exit(1) directory = Path(positional[0]) if not directory.is_dir(): print(f"Not a directory: {directory}") sys.exit(1) files = sorted(directory.rglob("*.contract.md")) if not files: print(f"No .contract.md files found in {directory}") sys.exit(0) any_errors = False for path in files: try: contract = parse_contract(path) if json_output: print(json.dumps({str(path): _to_dict(contract)}, indent=2)) elif validate_mode: issues = validate_contract(contract) print_validation(contract, issues) if any(i.severity == "error" for i in issues): any_errors = True elif list_mode: print_list(contract) print() else: print_human(contract) print("\u2500" * 60) except Exception as exc: print(f"\u2717 {path} \u2014 PARSE ERROR: {exc}") any_errors = True sys.exit(1 if any_errors else 0) if not positional: print("Provide a .contract.md file path") sys.exit(1) path = Path(positional[0]) if not path.exists(): print(f"File not found: {path}") sys.exit(1) try: contract = parse_contract(path) except Exception as exc: print(f"Parse error: {exc}") sys.exit(1) if json_output: print(json.dumps(_to_dict(contract), indent=2)) elif validate_mode: issues = validate_contract(contract) print_validation(contract, issues) if any(i.severity == "error" for i in issues): sys.exit(1) elif list_mode: print_list(contract) else: print_human(contract) if __name__ == "__main__": main()