#!/usr/bin/env python3 """Conservatively compare Harness and Environment tool schemas. Constraint changes are reported for review rather than treated as semantic compatibility. External ``$ref`` targets are not loaded or resolved. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any class SchemaError(ValueError): """Raised when a schema file is malformed.""" def load_json(path: Path) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise SchemaError(f"cannot read {path}: {error}") from error def tool_list(value: Any) -> list[dict[str, Any]]: if isinstance(value, dict) and "tools" in value: value = value["tools"] if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): raise SchemaError("tool file must be a list or an object with a tools list") return value def normalize_tool(value: dict[str, Any]) -> dict[str, Any]: function = value.get("function") if isinstance(value.get("function"), dict) else {} name = value.get("name") or function.get("name") if not isinstance(name, str) or not name: raise SchemaError("every tool must have a name") def present(*choices: tuple[dict[str, Any], str], default: Any = None) -> Any: for source, key in choices: if key in source: return source[key] return default input_schema = present( (value, "input_schema"), (value, "parameters"), (function, "parameters"), ) result = { "name": name, "input": input_schema, "output": present((value, "output_schema"), (function, "output_schema")), "error": present((value, "error_schema"), (function, "error_schema")), } for schema_name in ("input", "output", "error"): if result[schema_name] is not None: validate_schema(result[schema_name], f"tool {name} {schema_name}") return result def validate_schema(value: Any, path: str) -> None: """Reject malformed structure used by this conservative comparator.""" if isinstance(value, bool): return if not isinstance(value, dict): raise SchemaError(f"{path} schema must be an object or boolean") schema_type = value.get("type") if schema_type is not None: valid_types = {"array", "boolean", "integer", "null", "number", "object", "string"} types = [schema_type] if isinstance(schema_type, str) else schema_type if ( not isinstance(types, list) or not types or not all(isinstance(item, str) and item in valid_types for item in types) or len(types) != len(set(types)) ): raise SchemaError(f"{path}.type is invalid") required = value.get("required") if required is not None and ( not isinstance(required, list) or not all(isinstance(item, str) for item in required) or len(required) != len(set(required)) ): raise SchemaError(f"{path}.required must be a list of unique strings") properties = value.get("properties") if properties is not None: if not isinstance(properties, dict) or not all(isinstance(name, str) for name in properties): raise SchemaError(f"{path}.properties must be an object") for name, child in properties.items(): validate_schema(child, f"{path}.properties.{name}") if "items" in value: validate_schema(value["items"], f"{path}.items") for keyword in ("additionalProperties", "contains", "not", "propertyNames", "unevaluatedItems", "unevaluatedProperties"): if keyword in value: validate_schema(value[keyword], f"{path}.{keyword}") for keyword in ("allOf", "anyOf", "oneOf", "prefixItems"): if keyword in value: children = value[keyword] if not isinstance(children, list): raise SchemaError(f"{path}.{keyword} must be a list") for index, child in enumerate(children): validate_schema(child, f"{path}.{keyword}[{index}]") enum = value.get("enum") if enum is not None and (not isinstance(enum, list) or not enum): raise SchemaError(f"{path}.enum must be a non-empty list") def index_tools(value: Any) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for raw in tool_list(value): tool = normalize_tool(raw) if tool["name"] in result: raise SchemaError(f"duplicate tool: {tool['name']}") result[tool["name"]] = tool return result def compare_schema( required: Any, actual: Any, path: str, findings: list[dict[str, str]], additions: list[dict[str, str]], input_schema: bool, ) -> None: if isinstance(required, bool) or isinstance(actual, bool): if required != actual: findings.append( {"path": path, "issue": "boolean_schema_changed", "required": repr(required), "actual": repr(actual)} ) return if required is None: return if not isinstance(required, dict) or not isinstance(actual, dict): if required != actual: findings.append( {"path": path, "issue": "value_changed", "required": repr(required), "actual": repr(actual)} ) return required_type = required.get("type") actual_type = actual.get("type") if required_type != actual_type: findings.append( {"path": f"{path}.type", "issue": "type_changed", "required": str(required_type), "actual": str(actual_type)} ) required_enum = required.get("enum") actual_enum = actual.get("enum") if isinstance(required_enum, list): required_values = {json.dumps(value, sort_keys=True, separators=(",", ":")) for value in required_enum} actual_values = ( {json.dumps(value, sort_keys=True, separators=(",", ":")) for value in actual_enum} if isinstance(actual_enum, list) else set() ) if required_values != actual_values: findings.append( {"path": f"{path}.enum", "issue": "enum_changed", "required": repr(required_enum), "actual": repr(actual_enum)} ) elif actual_enum is not None: findings.append( {"path": f"{path}.enum", "issue": "enum_changed", "required": "", "actual": repr(actual_enum)} ) strict_keywords = { "$ref", "additionalProperties", "allOf", "anyOf", "const", "contains", "dependentRequired", "dependentSchemas", "else", "exclusiveMaximum", "exclusiveMinimum", "format", "if", "maxContains", "maxItems", "maxLength", "maxProperties", "maximum", "minContains", "minItems", "minLength", "minProperties", "minimum", "multipleOf", "not", "oneOf", "pattern", "patternProperties", "prefixItems", "propertyNames", "then", "unevaluatedItems", "unevaluatedProperties", "uniqueItems", } for keyword in sorted(strict_keywords): if keyword not in required and keyword not in actual: continue required_value = required.get(keyword, "") actual_value = actual.get(keyword, "") if required_value != actual_value: findings.append( { "path": f"{path}.{keyword}", "issue": "constraint_changed", "required": repr(required_value), "actual": repr(actual_value), } ) annotations = { "$comment", "$id", "$schema", "default", "deprecated", "description", "examples", "readOnly", "title", "writeOnly", } handled = {"type", "enum", "required", "properties", "items"} | strict_keywords | annotations for keyword in sorted((set(required) | set(actual)) - handled): if required.get(keyword, "") != actual.get(keyword, ""): findings.append( { "path": f"{path}.{keyword}", "issue": "unknown_constraint_changed", "required": repr(required.get(keyword, "")), "actual": repr(actual.get(keyword, "")), } ) required_required = set(required.get("required", [])) actual_required = set(actual.get("required", [])) if required_required != actual_required: findings.append( { "path": f"{path}.required", "issue": "required_fields_changed", "required": repr(sorted(required_required)), "actual": repr(sorted(actual_required)), } ) required_properties = required.get("properties", {}) actual_properties = actual.get("properties", {}) if isinstance(required_properties, dict): if not isinstance(actual_properties, dict): actual_properties = {} for name, child in required_properties.items(): if name not in actual_properties: findings.append( {"path": f"{path}.properties.{name}", "issue": "property_missing", "required": "present", "actual": "missing"} ) else: compare_schema( child, actual_properties[name], f"{path}.properties.{name}", findings, additions, input_schema, ) for name in sorted(set(actual_properties) - set(required_properties)): if input_schema and name not in actual_required: additions.append( {"path": f"{path}.properties.{name}", "change": "optional_property_added"} ) else: findings.append( { "path": f"{path}.properties.{name}", "issue": "property_added", "required": "missing", "actual": "present", } ) if "items" in required or "items" in actual: if "items" not in actual: findings.append( {"path": f"{path}.items", "issue": "items_schema_missing", "required": "present", "actual": "missing"} ) elif "items" not in required: findings.append( {"path": f"{path}.items", "issue": "items_schema_added", "required": "missing", "actual": "present"} ) else: compare_schema( required["items"], actual["items"], f"{path}.items", findings, additions, input_schema, ) def compare(required_path: Path, actual_path: Path) -> dict[str, Any]: required = index_tools(load_json(required_path)) actual = index_tools(load_json(actual_path)) findings: list[dict[str, str]] = [] additions: list[dict[str, str]] = [] for name in sorted(set(actual) - set(required)): additions.append({"path": f"tools.{name}", "change": "tool_added"}) for name, tool in required.items(): if name not in actual: findings.append( {"path": f"tools.{name}", "issue": "tool_missing", "required": "present", "actual": "missing"} ) continue for schema_name in ("input", "output", "error"): if (tool[schema_name] is None) != (actual[name][schema_name] is None): findings.append( { "path": f"tools.{name}.{schema_name}", "issue": "schema_presence_changed", "required": "present" if tool[schema_name] is not None else "missing", "actual": "present" if actual[name][schema_name] is not None else "missing", } ) elif tool[schema_name] is not None: compare_schema( tool[schema_name], actual[name][schema_name], f"tools.{name}.{schema_name}", findings, additions, schema_name == "input", ) return { "compatible": not findings, "required_tools": sorted(required), "actual_tools": sorted(actual), "additions": additions, "findings": findings, "limitations": [ "external JSON Schema $ref targets are not resolved", "constraint changes are reported conservatively and require review", ], } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("required", type=Path, help="Harness tool schema JSON") parser.add_argument("actual", type=Path, help="Environment tool schema JSON") parser.add_argument("--output", type=Path) args = parser.parse_args() try: report = compare(args.required, args.actual) except (OSError, SchemaError) as error: print(f"ERROR: {error}", file=sys.stderr) return 2 text = json.dumps(report, indent=2, sort_keys=True) + "\n" try: if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(text, encoding="utf-8") else: print(text, end="") except OSError as error: print(f"ERROR: {error}", file=sys.stderr) return 2 return 0 if report["compatible"] else 1 if __name__ == "__main__": raise SystemExit(main())