X-S3-01 Claim source check

Field Value
Purpose Check whether every claim in a chapter’s claim list cites a source id that is actually present among the admitted sources, and fail loudly, rather than silently, if no admitted sources are found at all.
Usage python3 scripts/s3/claim_source_check.py –help In a shell: python3 scripts/s3/claim_source_check.py CLAIMS.json SOURCES_DIR
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs CLAIMS.json, a list of {“claim_id”, “text”, “source_id”} objects. SOURCES_DIR, a folder of admitted source files, each named “SRC-<id>.md”, read-only and never copied.
Outputs One line per claim (“pass: …” or “gap: …”), then a summary line, all printed to standard output.
Used in S3.1 Layer 1 automated detection
Source scripts/s3/claim_source_check.py

Source code

"""
ID: X-S3-01
Title: Claim source check
Stage: S3
Purpose: Check whether every claim in a chapter's claim list cites a
    source id that is actually present among the admitted sources,
    and fail loudly, rather than silently, if no admitted sources are
    found at all.
Usage: python3 scripts/s3/claim_source_check.py --help
    In a shell: python3 scripts/s3/claim_source_check.py CLAIMS.json
    SOURCES_DIR
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: CLAIMS.json, a list of {"claim_id", "text", "source_id"}
    objects. SOURCES_DIR, a folder of admitted source files, each
    named "SRC-<id>.md", read-only and never copied.
Outputs: One line per claim ("pass: ..." or "gap: ..."), then a
    summary line, all printed to standard output.

This script checks one thing only: whether a claim's cited source id
names a source file that is actually present in SOURCES_DIR. It does
not check whether that source's own text actually supports the
claim; a later, person-led review does that.

If SOURCES_DIR holds zero files named "SRC-*.md", the script treats
this as a usage or input error (exit 2) with a clear message, instead
of silently reporting every claim as a gap. A folder that looks like
an admitted-sources folder but happens to be empty, perhaps because a
file-matching pattern went stale after a rename, is a setup problem,
not a finding about the claims.
"""

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, NamedTuple

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "claim_source_check.py: this script needs Python 3.10 or newer, "
        f"but this 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
SOURCE_FILENAME_RE = re.compile(r"^SRC-[A-Za-z0-9._-]+\.md$")


class Claim(NamedTuple):
    """One claim, as read from claims.json."""

    claim_id: str
    text: str
    source_id: str


def load_json(path: Path) -> Any:
    """Read and parse one JSON 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 load_claims(data: Any) -> list[Claim]:
    """Return a validated list of Claim entries, in file order."""
    if not isinstance(data, list):
        raise ValueError("claims.json is not a JSON list")
    claims: list[Claim] = []
    for position, entry in enumerate(data, start=1):
        if not isinstance(entry, dict):
            raise ValueError(f"claims entry #{position} is not a JSON object")
        claim_id = entry.get("claim_id")
        if not isinstance(claim_id, str) or not claim_id:
            raise ValueError(f"claims entry #{position} has no claim_id")
        text = entry.get("text")
        if not isinstance(text, str) or not text:
            raise ValueError(f'claim "{claim_id}" has no text')
        source_id = entry.get("source_id")
        if not isinstance(source_id, str) or not source_id:
            raise ValueError(f'claim "{claim_id}" has no source_id')
        claims.append(Claim(claim_id, text, source_id))
    return claims


def admitted_source_ids(sources_dir: Path) -> set[str]:
    """Return the source ids present as SRC-*.md files in sources_dir.

    Only a regular file counts. A symlink is skipped even when its
    name matches the pattern, since this script never follows one.
    """
    if sources_dir.is_symlink():
        raise OSError(f"refusing to read a symlink: {sources_dir}")
    if not sources_dir.is_dir():
        raise ValueError(f"{sources_dir} is not a directory")
    ids: set[str] = set()
    for entry in sources_dir.iterdir():
        if entry.is_symlink() or not entry.is_file():
            continue
        if SOURCE_FILENAME_RE.match(entry.name):
            ids.add(entry.name[: -len(".md")])
    return ids


def check_claims(claims: list[Claim], admitted_ids: set[str]) -> list[str]:
    """Return one "pass: ..." or "gap: ..." line per claim."""
    lines: list[str] = []
    for claim in claims:
        if claim.source_id not in admitted_ids:
            lines.append(
                f'gap: claim "{claim.claim_id}" cites "{claim.source_id}", '
                "no such source is admitted"
            )
        else:
            lines.append(
                f'pass: claim "{claim.claim_id}" cites "{claim.source_id}", ' "admitted"
            )
    return lines


def build_parser() -> argparse.ArgumentParser:
    """Build the command-line parser."""
    parser = argparse.ArgumentParser(
        prog="claim_source_check.py",
        description=(
            "Check whether every claim in a chapter's claim list cites a "
            "source id that is actually present in an admitted-sources "
            "folder. Writes no files."
        ),
    )
    parser.add_argument("claims", metavar="CLAIMS.json", help="the claims JSON file")
    parser.add_argument(
        "sources_dir", metavar="SOURCES_DIR", help="the admitted-sources folder"
    )
    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:
        claims = load_claims(load_json(Path(args.claims)))
        admitted_ids = admitted_source_ids(Path(args.sources_dir))
        if not admitted_ids:
            raise ValueError("no admitted sources found in SOURCES_DIR")
        findings = check_claims(claims, admitted_ids)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"claim_source_check.py: error: {exc}", file=sys.stderr)
        return 2
    for line in findings:
        print(line)
    gaps = sum(1 for line in findings if line.startswith("gap:"))
    print(f"claims={len(findings)} gaps={gaps}")
    return 1 if gaps else 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.