← Files LegalQuants LitigationARCHIVED FILE

skills/docreview/scripts/shared/validate_framework.py

21.1 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env python3
"""Validate a framework.json against framework.schema.json, stdlib only.

Schema enforcement is driven by the schema file itself: type, enum, const,
pattern, minLength, maxLength, minimum, maximum, minItems, maxItems,
required, properties, additionalProperties, items, and anyOf are applied
recursively, so schema edits keep working without code changes. Keywords
the checker does not implement are reported as a note, never silently
ignored into a false pass.

Semantic checks beyond the schema include frame/target invariants for enumerated
request instruments. Census bijection is enforced when --instruments is given.

Usage:
    python3 validate_framework.py --framework framework.json \
        [--schema ../references/framework.schema.json] [--allow-approved]

Exit codes: 0 valid, 1 invalid, 2 I/O or unusable schema.
"""

import argparse
import json
import os
import re
import sys


def resolve_default_schema():
    """Find the schema in source, direct-package, or relocated-shared layout."""
    script_dir = os.path.dirname(os.path.abspath(__file__))
    candidates = [
        os.path.normpath(
            os.path.join(script_dir, "..", "references", "framework.schema.json")
        ),
        os.path.normpath(
            os.path.join(
                script_dir,
                "..",
                "..",
                "references",
                "shared",
                "framework.schema.json",
            )
        ),
    ]
    return next((path for path in candidates if os.path.isfile(path)), candidates[0])


DEFAULT_SCHEMA = resolve_default_schema()

TYPE_CHECKS = {
    "object": lambda v: isinstance(v, dict),
    "array": lambda v: isinstance(v, list),
    "string": lambda v: isinstance(v, str),
    "integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
    "number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
    "boolean": lambda v: isinstance(v, bool),
    "null": lambda v: v is None,
}

# Keywords this checker enforces, plus pure metadata it may skip.
HANDLED = {
    "type",
    "enum",
    "const",
    "pattern",
    "minLength",
    "maxLength",
    "minimum",
    "maximum",
    "minItems",
    "maxItems",
    "required",
    "properties",
    "additionalProperties",
    "items",
    "anyOf",
}
METADATA = {"$schema", "$id", "title", "description", "examples", "default"}
SERIES_PATTERN = re.compile(r"^[A-Z]+$")


def served_designator(series, number):
    return f"{series}-{number}" if series is not None else str(number)


def target_sort_key(key):
    instrument_id, series, number = key
    return (
        str(instrument_id),
        series if isinstance(series, str) else repr(series),
        number if isinstance(number, int) and not isinstance(number, bool) else -1,
    )


def type_name(value):
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "boolean"
    if isinstance(value, int):
        return "integer"
    if isinstance(value, float):
        return "number"
    return {dict: "object", list: "array", str: "string"}.get(
        type(value), type(value).__name__
    )


def json_equal(a, b):
    """Equality that keeps booleans apart from 0/1."""
    if isinstance(a, bool) != isinstance(b, bool):
        return False
    return a == b


def check_schema(value, schema, path, errors, unknown):
    """Apply one schema node to value, appending error strings with JSON paths."""
    for k in schema:
        if k not in HANDLED and k not in METADATA:
            unknown.add(k)

    if "anyOf" in schema:
        branch_fails = []
        for branch in schema["anyOf"]:
            trial = []
            check_schema(value, branch, path, trial, unknown)
            if not trial:
                branch_fails = None
                break
            branch_fails.append(trial[0])
        if branch_fails is not None:
            errors.append(
                "{}: no anyOf alternative matched ({})".format(
                    path, " | ".join(branch_fails)
                )
            )

    if "type" in schema:
        types = schema["type"]
        if not isinstance(types, list):
            types = [types]
        if not any(TYPE_CHECKS[t](value) for t in types):
            errors.append(
                "{}: expected type {}, got {}".format(
                    path, "/".join(types), type_name(value)
                )
            )
            return  # remaining keywords assume the declared type

    if "enum" in schema and not any(json_equal(value, o) for o in schema["enum"]):
        errors.append("{}: {!r} is not one of {}".format(path, value, schema["enum"]))
    if "const" in schema and not json_equal(value, schema["const"]):
        errors.append(
            "{}: must be exactly {!r}, got {!r}".format(path, schema["const"], value)
        )

    if isinstance(value, str):
        if "pattern" in schema and not re.search(schema["pattern"], value):
            errors.append(
                "{}: {!r} does not match pattern {}".format(
                    path, value, schema["pattern"]
                )
            )
        if "minLength" in schema and len(value) < schema["minLength"]:
            errors.append(
                "%s: string shorter than minLength %d" % (path, schema["minLength"])
            )
        if "maxLength" in schema and len(value) > schema["maxLength"]:
            errors.append(
                "%s: string longer than maxLength %d" % (path, schema["maxLength"])
            )

    if isinstance(value, (int, float)) and not isinstance(value, bool):
        if "minimum" in schema and value < schema["minimum"]:
            errors.append(
                "{}: {!r} is below minimum {!r}".format(path, value, schema["minimum"])
            )
        if "maximum" in schema and value > schema["maximum"]:
            errors.append(
                "{}: {!r} is above maximum {!r}".format(path, value, schema["maximum"])
            )

    if isinstance(value, list):
        if "minItems" in schema and len(value) < schema["minItems"]:
            errors.append(
                "%s: array has %d item(s), minItems is %d"
                % (path, len(value), schema["minItems"])
            )
        if "maxItems" in schema and len(value) > schema["maxItems"]:
            errors.append(
                "%s: array has %d item(s), maxItems is %d"
                % (path, len(value), schema["maxItems"])
            )
        if "items" in schema:
            for i, elem in enumerate(value):
                check_schema(
                    elem, schema["items"], "%s[%d]" % (path, i), errors, unknown
                )

    if isinstance(value, dict):
        props = schema.get("properties", {})
        for req in schema.get("required", []):
            if req not in value:
                errors.append(f"{path}: missing required property '{req}'")
        for k, v in value.items():
            if k in props:
                check_schema(v, props[k], f"{path}.{k}", errors, unknown)
            elif "additionalProperties" in schema:
                ap = schema["additionalProperties"]
                if ap is False:
                    errors.append(f"{path}: unexpected property '{k}'")
                elif isinstance(ap, dict):
                    check_schema(v, ap, f"{path}.{k}", errors, unknown)

    return errors


def semantic_errors(fw, allow_approved=False, instruments=None, manifest=None):
    """Cross-field checks the schema language cannot express."""
    errors = []
    if not isinstance(fw, dict):
        return errors

    v = fw.get("framework_version")
    if isinstance(v, int) and not isinstance(v, bool) and v < 1:
        errors.append("$.framework_version: must be >= 1, got %d" % v)
    if fw.get("approved") is True and not allow_approved:
        errors.append(
            "$.approved: must be false before sample-framework approval "
            "(pass --allow-approved to accept an approved framework)"
        )

    lenses = fw.get("lenses")
    if not isinstance(lenses, list):
        return errors
    lens_ids = {}
    issue_ids = {}
    for i, lens in enumerate(lenses):
        if not isinstance(lens, dict):
            continue
        lid = lens.get("lens_id")
        lpath = "$.lenses[%d]" % i
        if isinstance(lid, str):
            if lid in lens_ids:
                errors.append(
                    f"{lpath}.lens_id: duplicate lens_id {lid!r} (first used at {lens_ids[lid]})"
                )
            else:
                lens_ids[lid] = lpath
        items = lens.get("items")
        if not isinstance(items, list):
            continue
        for j, item in enumerate(items):
            if not isinstance(item, dict):
                continue
            iid = item.get("issue_id")
            ipath = "%s.items[%d]" % (lpath, j)
            if isinstance(iid, str):
                if iid in issue_ids:
                    errors.append(
                        f"{ipath}.issue_id: duplicate issue_id {iid!r} (first used at {issue_ids[iid]}); "
                        "issue_id must be unique across all lenses"
                    )
                else:
                    issue_ids[iid] = ipath
    for i, lens in enumerate(lenses):
        if not isinstance(lens, dict):
            continue
        for j, item in enumerate(lens.get("items") or []):
            if not isinstance(item, dict):
                continue
            owner = item.get("overlap_owner")
            if owner is not None and isinstance(owner, str) and owner not in lens_ids:
                errors.append(
                    "$.lenses[%d].items[%d].overlap_owner: %r is not a "
                    "lens_id defined in this framework" % (i, j, owner)
                )
    frame = fw.get("frame")
    if not isinstance(frame, dict):
        return errors

    frame_rows = frame.get("instruments")
    if not isinstance(frame_rows, list):
        return errors
    frame_by_id = {}
    for index, row in enumerate(frame_rows):
        if not isinstance(row, dict):
            continue
        instrument_id = row.get("instrument_id")
        if not isinstance(instrument_id, str):
            continue
        if instrument_id in frame_by_id:
            errors.append(
                f"$.frame.instruments[{index}].instrument_id: duplicate "
                f"instrument_id {instrument_id!r}"
            )
        else:
            frame_by_id[instrument_id] = row

    frame_corpus = frame.get("corpus_id")
    manifest_corpus = manifest.get("corpus_id") if isinstance(manifest, dict) else None
    if (
        isinstance(frame_corpus, str)
        and isinstance(manifest_corpus, str)
        and frame_corpus != manifest_corpus
    ):
        errors.append(
            f"$.frame.corpus_id: {frame_corpus!r} does not match manifest "
            f"corpus_id {manifest_corpus!r}"
        )

    frame_kind = frame.get("kind")
    if frame_kind == "issues":
        for i, lens in enumerate(lenses):
            if not isinstance(lens, dict):
                continue
            for j, item in enumerate(lens.get("items") or []):
                if isinstance(item, dict) and "target_ref" in item:
                    errors.append(
                        f"$.lenses[{i}].items[{j}].target_ref: forbidden in an "
                        "issues frame"
                    )
        return errors
    if frame_kind != "requests":
        return errors

    targets = {}
    instrument_target_counts = {instrument_id: 0 for instrument_id in frame_by_id}
    for i, lens in enumerate(lenses):
        if not isinstance(lens, dict):
            continue
        lens_id = lens.get("lens_id")
        for j, item in enumerate(lens.get("items") or []):
            if not isinstance(item, dict):
                continue
            path = f"$.lenses[{i}].items[{j}]"
            target = item.get("target_ref")
            if not isinstance(target, dict):
                errors.append(
                    f"{path}.target_ref: every requests-frame item requires one "
                    "singular target_ref"
                )
                continue
            instrument_id = target.get("instrument_id")
            element = target.get("element")
            series = target.get("series")
            series_key = (
                series if series is None or isinstance(series, str) else repr(series)
            )
            if instrument_id not in frame_by_id:
                errors.append(
                    f"{path}.target_ref.instrument_id: {instrument_id!r} is not "
                    "declared in frame.instruments"
                )
                continue
            frame_row = frame_by_id[instrument_id]
            if frame_row.get("staged") is True:
                errors.append(
                    f"{path}.target_ref: staged instrument {instrument_id!r} "
                    "cannot be targeted"
                )
            instrument_target_counts[instrument_id] += 1
            if not (
                isinstance(lens_id, str)
                and (
                    lens_id == instrument_id or lens_id.startswith(instrument_id + "-")
                )
            ):
                errors.append(
                    f"{path}.target_ref: instrument {instrument_id!r} does not "
                    f"align with lens {lens_id!r}"
                )
            key = (instrument_id, series_key, element)
            targets.setdefault(key, []).append(path)
    for key, paths in sorted(
        targets.items(), key=lambda pair: target_sort_key(pair[0])
    ):
        if len(paths) > 1:
            errors.append(
                f"$.frame: target {key[0]} no. "
                f"{served_designator(key[1], key[2])} is targeted more than "
                f"once ({', '.join(paths)})"
            )
    for instrument_id, row in sorted(frame_by_id.items()):
        if (
            row.get("staged") is not True
            and instrument_target_counts[instrument_id] == 0
        ):
            errors.append(
                f"$.frame.instruments: non-staged instrument {instrument_id!r} "
                "has no lens items"
            )

    if instruments is None:
        return errors
    census_rows = (
        instruments.get("instruments") if isinstance(instruments, dict) else None
    )
    if not isinstance(census_rows, list):
        errors.append("instruments.json: missing instruments array")
        return errors
    census_by_id = {}
    element_sets = {}
    for index, row in enumerate(census_rows):
        if not isinstance(row, dict):
            errors.append(f"instruments.json.instruments[{index}]: must be an object")
            continue
        instrument_id = row.get("instrument_id")
        if not isinstance(instrument_id, str):
            errors.append(
                f"instruments.json.instruments[{index}]: missing instrument_id"
            )
            continue
        if instrument_id in census_by_id:
            errors.append(
                f"instruments.json: duplicate instrument_id {instrument_id!r}"
            )
            continue
        census_by_id[instrument_id] = row
        identities = []
        for element_index, element_row in enumerate(row.get("elements") or []):
            if not isinstance(element_row, dict) or not isinstance(
                element_row.get("no"), int
            ):
                continue
            series = element_row.get("series")
            if "series" in element_row and not (
                isinstance(series, str) and SERIES_PATTERN.fullmatch(series)
            ):
                errors.append(
                    f"instruments.json: instrument {instrument_id!r} element row "
                    f"{element_index} has invalid series {series!r}; expected ^[A-Z]+$"
                )
                continue
            identity = (series, element_row["no"])
            if identity in identities:
                errors.append(
                    f"instruments.json: instrument {instrument_id!r} has duplicate "
                    f"element designator {served_designator(*identity)}"
                )
            identities.append(identity)
        element_sets[instrument_id] = set(identities)
    missing_frame = sorted(set(census_by_id) - set(frame_by_id))
    extra_frame = sorted(set(frame_by_id) - set(census_by_id))
    if missing_frame:
        errors.append(
            "$.frame.instruments: census instrument(s) missing from frame: "
            + ", ".join(missing_frame)
        )
    if extra_frame:
        errors.append(
            "$.frame.instruments: frame instrument(s) absent from census: "
            + ", ".join(extra_frame)
        )
    for instrument_id in sorted(set(frame_by_id) & set(census_by_id)):
        frame_row = frame_by_id[instrument_id]
        census_row = census_by_id[instrument_id]
        if frame_row.get("kind") != census_row.get("kind"):
            errors.append(
                f"$.frame.instruments: {instrument_id!r} kind does not match census"
            )
        if frame_row.get("element_count") != len(census_row.get("elements") or []):
            errors.append(
                f"$.frame.instruments: {instrument_id!r} element_count does not "
                "match census"
            )
        for key in sorted(
            (target for target in targets if target[0] == instrument_id),
            key=target_sort_key,
        ):
            identity = (key[1], key[2])
            if identity not in element_sets[instrument_id]:
                errors.append(
                    f"$.frame: target {instrument_id} no. "
                    f"{served_designator(*identity)} does not exist "
                    "in instruments.json"
                )
        if frame_row.get("staged") is True:
            continue
        for identity in sorted(
            element_sets[instrument_id],
            key=lambda value: (value[0] or "", value[1]),
        ):
            count = len(targets.get((instrument_id, identity[0], identity[1]), []))
            designator = served_designator(*identity)
            if count == 0:
                errors.append(
                    f"$.frame: {instrument_id} no. {designator} is not targeted"
                )
            elif count > 1:
                errors.append(
                    f"$.frame: {instrument_id} no. {designator} is targeted "
                    f"{count} times"
                )
    return errors


def validate(framework, schema, allow_approved=False, instruments=None, manifest=None):
    """Full validation. Returns (errors, unknown_keywords)."""
    errors = []
    unknown = set()
    check_schema(framework, schema, "$", errors, unknown)
    errors.extend(semantic_errors(framework, allow_approved, instruments, manifest))
    return errors, unknown


def load_json(path, kind):
    """Load a JSON file or raise ValueError with a readable message."""
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        raise ValueError(f"cannot read {kind} at {path}: {e}") from e


def main():
    ap = argparse.ArgumentParser(
        description="Validate framework.json against framework.schema.json."
    )
    ap.add_argument("--framework", required=True, help="framework.json to validate.")
    ap.add_argument(
        "--schema",
        default=DEFAULT_SCHEMA,
        help=f"Schema file (default: {DEFAULT_SCHEMA}).",
    )
    ap.add_argument(
        "--instruments",
        default=None,
        help="Optional instruments.json census for requests-mode bijection checks.",
    )
    ap.add_argument(
        "--manifest",
        default=None,
        help="Optional manifest.json for frame corpus_id binding.",
    )
    ap.add_argument(
        "--allow-approved",
        action="store_true",
        help="Accept approved: true after sample-framework approval.",
    )
    args = ap.parse_args()

    try:
        framework = load_json(args.framework, "framework")
        schema = load_json(args.schema, "schema")
        instruments = (
            load_json(args.instruments, "instruments") if args.instruments else None
        )
        manifest = load_json(args.manifest, "manifest") if args.manifest else None
    except ValueError as e:
        print(f"FATAL: {e}", file=sys.stderr)
        sys.exit(2)

    errors, unknown = validate(
        framework, schema, args.allow_approved, instruments, manifest
    )
    if unknown:
        print(
            "note: schema uses keyword(s) this checker does not enforce: {}".format(
                ", ".join(sorted(unknown))
            ),
            file=sys.stderr,
        )
    if errors:
        print("INVALID: %s (%d error(s))" % (args.framework, len(errors)))
        for e in errors:
            print("  " + e)
        sys.exit(1)
    if (
        isinstance(framework.get("frame"), dict)
        and framework["frame"].get("kind") == "requests"
        and instruments is None
    ):
        print(
            "note: requests-mode structural checks passed, but census/bijection "
            "was not proven; rerun with --instruments",
            file=sys.stderr,
        )
    n_items = sum(len(lens.get("items", [])) for lens in framework.get("lenses", []))
    print(
        "OK: %s is valid (%d lens(es), %d item(s), version %s)"
        % (
            args.framework,
            len(framework.get("lenses", [])),
            n_items,
            framework.get("framework_version"),
        )
    )


if __name__ == "__main__":
    main()

SHA-256: ef76c0ef6fb0a67597967cc217023d77e7a2d09f98cd65a59578bfb8c346b506