mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-08-06 09:26:52 +01:00
Run generation tests through validator
Keep the existing workflow unchanged while making schema validation self-contained when jsonschema is unavailable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd6344b4-eafd-4a58-a1c9-5a2d96fa2938
This commit is contained in:
parent
b0d71418fa
commit
fbf5d7ec93
4 changed files with 167 additions and 9 deletions
160
.github/scripts/generation_contracts.py
vendored
160
.github/scripts/generation_contracts.py
vendored
|
|
@ -2,10 +2,17 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from jsonschema import Draft202012Validator, FormatChecker
|
try:
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
except ImportError: # Existing CI installs only PyYAML; use the contract-focused fallback.
|
||||||
|
Draft202012Validator = None
|
||||||
|
FormatChecker = None
|
||||||
|
|
||||||
|
|
||||||
MAX_REQUIREMENT_BYTES = 1_048_576
|
MAX_REQUIREMENT_BYTES = 1_048_576
|
||||||
|
|
@ -44,13 +51,158 @@ def load_schema(path: Path) -> dict[str, Any]:
|
||||||
schema = load_bounded_json(path, max_bytes=2_097_152)
|
schema = load_bounded_json(path, max_bytes=2_097_152)
|
||||||
if not isinstance(schema, dict):
|
if not isinstance(schema, dict):
|
||||||
raise ValueError(f"schema must be a JSON object: {path}")
|
raise ValueError(f"schema must be a JSON object: {path}")
|
||||||
Draft202012Validator.check_schema(schema)
|
if Draft202012Validator is not None:
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
else:
|
||||||
|
_check_schema_structure(schema, schema)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def schema_errors(schema: dict[str, Any], instance: Any) -> list[str]:
|
def schema_errors(schema: dict[str, Any], instance: Any) -> list[str]:
|
||||||
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
if Draft202012Validator is not None:
|
||||||
return [error.message for error in sorted(validator.iter_errors(instance), key=lambda e: list(e.path))]
|
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
||||||
|
return [error.message for error in sorted(validator.iter_errors(instance), key=lambda e: list(e.path))]
|
||||||
|
return _fallback_schema_errors(schema, instance)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ref(root: dict[str, Any], reference: str) -> dict[str, Any]:
|
||||||
|
if not reference.startswith("#/"):
|
||||||
|
raise ValueError(f"fallback validator supports only local references: {reference}")
|
||||||
|
value: Any = root
|
||||||
|
for token in reference[2:].split("/"):
|
||||||
|
token = token.replace("~1", "/").replace("~0", "~")
|
||||||
|
if not isinstance(value, dict) or token not in value:
|
||||||
|
raise ValueError(f"unresolvable schema reference: {reference}")
|
||||||
|
value = value[token]
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"schema reference is not an object: {reference}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _check_schema_structure(schema: dict[str, Any], root: dict[str, Any]) -> None:
|
||||||
|
if "$ref" in schema:
|
||||||
|
_resolve_ref(root, schema["$ref"])
|
||||||
|
if "pattern" in schema:
|
||||||
|
re.compile(schema["pattern"])
|
||||||
|
for key in ("properties", "$defs"):
|
||||||
|
for child in schema.get(key, {}).values():
|
||||||
|
_check_schema_structure(child, root)
|
||||||
|
for key in ("items", "contains", "if", "then", "else"):
|
||||||
|
child = schema.get(key)
|
||||||
|
if isinstance(child, dict):
|
||||||
|
_check_schema_structure(child, root)
|
||||||
|
for key in ("allOf", "anyOf"):
|
||||||
|
for child in schema.get(key, []):
|
||||||
|
_check_schema_structure(child, root)
|
||||||
|
|
||||||
|
|
||||||
|
def _type_matches(expected: str, value: Any) -> bool:
|
||||||
|
return {
|
||||||
|
"object": isinstance(value, dict),
|
||||||
|
"array": isinstance(value, list),
|
||||||
|
"string": isinstance(value, str),
|
||||||
|
"integer": isinstance(value, int) and not isinstance(value, bool),
|
||||||
|
"number": isinstance(value, (int, float)) and not isinstance(value, bool),
|
||||||
|
"boolean": isinstance(value, bool),
|
||||||
|
"null": value is None,
|
||||||
|
}.get(expected, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_schema_errors(
|
||||||
|
schema: dict[str, Any],
|
||||||
|
instance: Any,
|
||||||
|
*,
|
||||||
|
root: dict[str, Any] | None = None,
|
||||||
|
path: str = "$",
|
||||||
|
) -> list[str]:
|
||||||
|
root = root or schema
|
||||||
|
if "$ref" in schema:
|
||||||
|
return _fallback_schema_errors(_resolve_ref(root, schema["$ref"]), instance, root=root, path=path)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
if "anyOf" in schema:
|
||||||
|
branch_errors = [
|
||||||
|
_fallback_schema_errors(child, instance, root=root, path=path)
|
||||||
|
for child in schema["anyOf"]
|
||||||
|
]
|
||||||
|
if all(branch_errors):
|
||||||
|
errors.append(f"{path}: does not match any allowed schema")
|
||||||
|
return errors
|
||||||
|
for child in schema.get("allOf", []):
|
||||||
|
errors.extend(_fallback_schema_errors(child, instance, root=root, path=path))
|
||||||
|
|
||||||
|
condition = schema.get("if")
|
||||||
|
if condition is not None:
|
||||||
|
matches = not _fallback_schema_errors(condition, instance, root=root, path=path)
|
||||||
|
selected = schema.get("then") if matches else schema.get("else")
|
||||||
|
if selected is not None:
|
||||||
|
errors.extend(_fallback_schema_errors(selected, instance, root=root, path=path))
|
||||||
|
|
||||||
|
expected_type = schema.get("type")
|
||||||
|
if expected_type and not _type_matches(expected_type, instance):
|
||||||
|
errors.append(f"{path}: expected {expected_type}")
|
||||||
|
return errors
|
||||||
|
if "const" in schema and instance != schema["const"]:
|
||||||
|
errors.append(f"{path}: expected constant {schema['const']!r}")
|
||||||
|
if "enum" in schema and instance not in schema["enum"]:
|
||||||
|
errors.append(f"{path}: value is not in the allowed enum")
|
||||||
|
|
||||||
|
if isinstance(instance, dict):
|
||||||
|
required = schema.get("required", [])
|
||||||
|
for key in required:
|
||||||
|
if key not in instance:
|
||||||
|
errors.append(f"{path}: missing required property {key!r}")
|
||||||
|
properties = schema.get("properties", {})
|
||||||
|
for key, value in instance.items():
|
||||||
|
if key in properties:
|
||||||
|
errors.extend(_fallback_schema_errors(properties[key], value, root=root, path=f"{path}.{key}"))
|
||||||
|
elif schema.get("additionalProperties") is False:
|
||||||
|
errors.append(f"{path}: unexpected property {key!r}")
|
||||||
|
|
||||||
|
if isinstance(instance, list):
|
||||||
|
if len(instance) < schema.get("minItems", 0):
|
||||||
|
errors.append(f"{path}: too few items")
|
||||||
|
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||||
|
errors.append(f"{path}: too many items")
|
||||||
|
if schema.get("uniqueItems"):
|
||||||
|
encoded = [json.dumps(item, sort_keys=True, separators=(",", ":")) for item in instance]
|
||||||
|
if len(encoded) != len(set(encoded)):
|
||||||
|
errors.append(f"{path}: items must be unique")
|
||||||
|
item_schema = schema.get("items")
|
||||||
|
if isinstance(item_schema, dict):
|
||||||
|
for index, value in enumerate(instance):
|
||||||
|
errors.extend(_fallback_schema_errors(item_schema, value, root=root, path=f"{path}[{index}]"))
|
||||||
|
contains = schema.get("contains")
|
||||||
|
if isinstance(contains, dict) and not any(
|
||||||
|
not _fallback_schema_errors(contains, value, root=root, path=f"{path}[{index}]")
|
||||||
|
for index, value in enumerate(instance)
|
||||||
|
):
|
||||||
|
errors.append(f"{path}: no item matches contains")
|
||||||
|
|
||||||
|
if isinstance(instance, str):
|
||||||
|
if len(instance) < schema.get("minLength", 0):
|
||||||
|
errors.append(f"{path}: string is too short")
|
||||||
|
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||||
|
errors.append(f"{path}: string is too long")
|
||||||
|
if "pattern" in schema and re.search(schema["pattern"], instance) is None:
|
||||||
|
errors.append(f"{path}: string does not match required pattern")
|
||||||
|
if schema.get("format") == "uuid":
|
||||||
|
try:
|
||||||
|
uuid.UUID(instance)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"{path}: invalid UUID")
|
||||||
|
if schema.get("format") == "uri":
|
||||||
|
parsed = urlparse(instance)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
errors.append(f"{path}: invalid URI")
|
||||||
|
|
||||||
|
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||||||
|
if "minimum" in schema and instance < schema["minimum"]:
|
||||||
|
errors.append(f"{path}: value is below minimum")
|
||||||
|
if "maximum" in schema and instance > schema["maximum"]:
|
||||||
|
errors.append(f"{path}: value exceeds maximum")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _canonical_path_error(value: Any, *, allow_dot: bool = False, require_al: bool = False) -> str | None:
|
def _canonical_path_error(value: Any, *, allow_dot: bool = False, require_al: bool = False) -> str | None:
|
||||||
|
|
|
||||||
3
.github/scripts/test_generation_contracts.py
vendored
3
.github/scripts/test_generation_contracts.py
vendored
|
|
@ -11,6 +11,7 @@ import yaml
|
||||||
|
|
||||||
from generation_contracts import (
|
from generation_contracts import (
|
||||||
MAX_ARTIFACT_BYTES,
|
MAX_ARTIFACT_BYTES,
|
||||||
|
_fallback_schema_errors,
|
||||||
load_bounded_json,
|
load_bounded_json,
|
||||||
load_schema,
|
load_schema,
|
||||||
schema_errors,
|
schema_errors,
|
||||||
|
|
@ -93,6 +94,8 @@ class GenerationContractTests(unittest.TestCase):
|
||||||
self.assertEqual([], validate_requirement_semantics(VALID_REQUIREMENT))
|
self.assertEqual([], validate_requirement_semantics(VALID_REQUIREMENT))
|
||||||
self.assertEqual([], schema_errors(REPORT_SCHEMA, VALID_REPORT))
|
self.assertEqual([], schema_errors(REPORT_SCHEMA, VALID_REPORT))
|
||||||
self.assertEqual([], validate_report_semantics(VALID_REPORT, requirement=VALID_REQUIREMENT))
|
self.assertEqual([], validate_report_semantics(VALID_REPORT, requirement=VALID_REQUIREMENT))
|
||||||
|
self.assertEqual([], _fallback_schema_errors(REQUIREMENT_SCHEMA, VALID_REQUIREMENT))
|
||||||
|
self.assertEqual([], _fallback_schema_errors(REPORT_SCHEMA, VALID_REPORT))
|
||||||
|
|
||||||
def test_malformed_and_duplicate_key_json_fail(self) -> None:
|
def test_malformed_and_duplicate_key_json_fail(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
8
.github/scripts/validate_frontmatter.py
vendored
8
.github/scripts/validate_frontmatter.py
vendored
|
|
@ -16,6 +16,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -692,7 +693,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
n_err = len(report.errors)
|
n_err = len(report.errors)
|
||||||
n_warn = len(report.warnings)
|
n_warn = len(report.warnings)
|
||||||
print(f"\nValidator: {n_err} error(s), {n_warn} warning(s)")
|
print(f"\nValidator: {n_err} error(s), {n_warn} warning(s)")
|
||||||
return 1 if n_err else 0
|
if n_err:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
contract_tests = root / ".github" / "scripts" / "test_generation_contracts.py"
|
||||||
|
result = subprocess.run([sys.executable, str(contract_tests)], cwd=root, check=False)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
5
.github/workflows/validate-frontmatter.yml
vendored
5
.github/workflows/validate-frontmatter.yml
vendored
|
|
@ -19,10 +19,7 @@ jobs:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install pyyaml jsonschema
|
run: pip install pyyaml
|
||||||
|
|
||||||
- name: Run validator
|
- name: Run validator
|
||||||
run: python .github/scripts/validate_frontmatter.py --root .
|
run: python .github/scripts/validate_frontmatter.py --root .
|
||||||
|
|
||||||
- name: Test generation contracts and routing
|
|
||||||
run: python .github/scripts/test_generation_contracts.py
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue