X-S1-10 Gap check

Field Value
Purpose Flag each blueprint objective whose supported_by list has fewer entries than a minimum source count, and print a summary line.
Usage python3 scripts/s1/gap_check.py –help In a shell: python3 scripts/s1/gap_check.py BLUEPRINT.json [–min-sources N]
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A blueprint JSON file (see blueprint_check.py): a list of domains, each with a list of objectives. Each objective has an id and an optional supported_by list of source ids.
Outputs One line per gap, then a summary line, all printed to standard output.
Used in S1.8 Coverage and gaps
Source scripts/s1/gap_check.py

Source code

"""
ID: X-S1-10
Title: Gap check
Stage: S1
Purpose: Flag each blueprint objective whose supported_by list has fewer
    entries than a minimum source count, and print a summary line.
Usage: python3 scripts/s1/gap_check.py --help
    In a shell: python3 scripts/s1/gap_check.py BLUEPRINT.json
    [--min-sources N]
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A blueprint JSON file (see blueprint_check.py): a list of
    domains, each with a list of objectives. Each objective has an id
    and an optional supported_by list of source ids.
Outputs: One line per gap, then a summary line, all printed to standard
    output.

This script checks one criterion only: how many sources support each
objective, counted from its (possibly absent or empty) supported_by
list. It does not check a blueprint's structure, arithmetic or
cognitive levels; blueprint_check.py already does that, and a malformed
domain or objective here is skipped rather than reported.

A gap list is information a person reads, not a failure of this script,
so a gap never changes the exit code. The exit code is 0 unless the
input itself cannot be read.
"""

import argparse
import json
import sys
from pathlib import Path
from typing import Any

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "gap_check.py: this script needs Python 3.10 or newer, but this "
        f"is {sys.version_info.major}.{sys.version_info.minor}. Run it "
        "with a newer python3.",
        file=sys.stderr,
    )
    sys.exit(2)

MAX_BYTES = 5_000_000
DEFAULT_MIN_SOURCES = 1


def load_blueprint(path: Path) -> Any:
    """Read and parse the blueprint file; never follows a symlink."""
    if path.is_symlink():
        raise OSError(f"refusing to read a symlink: {path}")
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError(f"{path} is over {MAX_BYTES} bytes; skipping")
    text = path.read_text(encoding="utf-8", errors="replace")
    return json.loads(text)


def find_objectives(data: Any) -> list[tuple[str, int]]:
    """Return (objective id, supported_by count) for every objective.

    This walks the same domain and objective shape blueprint_check.py
    validates, but does not repeat that validation here: a domain or
    objective that is not a JSON object, or has no objectives list, is
    skipped rather than reported.
    """
    if not isinstance(data, dict):
        raise ValueError("the blueprint is not a JSON object")
    domains = data.get("domains")
    if not isinstance(domains, list):
        raise ValueError("'domains' is missing or is not a JSON list")

    found: list[tuple[str, int]] = []
    for dpos, domain in enumerate(domains, start=1):
        if not isinstance(domain, dict):
            continue
        objectives = domain.get("objectives")
        if not isinstance(objectives, list):
            continue
        for opos, objective in enumerate(objectives, start=1):
            if not isinstance(objective, dict):
                continue
            objective_id = objective.get("id")
            if not isinstance(objective_id, str) or not objective_id:
                objective_id = f"(domain #{dpos} objective #{opos})"
            supported_by = objective.get("supported_by")
            count = len(supported_by) if isinstance(supported_by, list) else 0
            found.append((objective_id, count))
    return found


def find_gaps(objectives: list[tuple[str, int]], min_sources: int) -> list[str]:
    """Return one 'gap ...' line for every objective under min_sources."""
    lines = []
    for objective_id, count in objectives:
        if count < min_sources:
            lines.append(
                f"gap {objective_id}: {count} source(s), "
                f"need at least {min_sources}"
            )
    return lines


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="gap_check.py",
        description=(
            "Flag each blueprint objective with fewer supporting sources "
            "than --min-sources, and print a summary. Writes no files."
        ),
    )
    parser.add_argument(
        "blueprint", metavar="BLUEPRINT", help="path to the blueprint JSON"
    )
    parser.add_argument(
        "--min-sources",
        type=int,
        default=DEFAULT_MIN_SOURCES,
        metavar="N",
        help=(
            "flag an objective with fewer than N entries in its "
            f"supported_by list (default: {DEFAULT_MIN_SOURCES})"
        ),
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    """Command line entry point; prints the report, returns an exit code."""
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        data = load_blueprint(Path(args.blueprint))
        objectives = find_objectives(data)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"gap_check.py: error: {exc}", file=sys.stderr)
        return 2
    gaps = find_gaps(objectives, args.min_sources)
    for line in gaps:
        print(line)
    print(f"objectives={len(objectives)} gaps={len(gaps)}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

To the extent possible under law, copyright and related rights in this work are waived under CC0 1.0 Universal.

This site uses Just the Docs, a documentation theme for Jekyll.