X-OP-03 Citation key check

Field Value
Purpose Check a body-text excerpt’s citation keys against a reference list, in both directions: a key used in the text but not defined in the reference list is an error, and a key defined but never used is a warning.
Usage python3 scripts/op/citation_key_check.py –help In a shell: python3 scripts/op/citation_key_check.py TEXT.md REFERENCES.json
Dependencies stdlib
Writes files no
License CC0-1.0
Inputs A body-text file (TEXT.md), read as plain text, holding zero or more citations written as a bracketed key, such as “[atomic-checkpoint]”; and a reference-list JSON file (REFERENCES.json), a JSON object mapping each citation key to a JSON object of that reference’s own metadata (a “title” field and any others).
Outputs One line per finding, then a summary line, all printed to standard output.
Used in Provenance and verification discipline
Source scripts/op/citation_key_check.py

Source code

"""
ID: X-OP-03
Title: Citation key check
Stage: OP
Purpose: Check a body-text excerpt's citation keys against a reference
    list, in both directions: a key used in the text but not defined in
    the reference list is an error, and a key defined but never used is
    a warning.
Usage: python3 scripts/op/citation_key_check.py --help
    In a shell: python3 scripts/op/citation_key_check.py TEXT.md
    REFERENCES.json
Dependencies: stdlib
Writes files: no
License: CC0-1.0
Inputs: A body-text file (TEXT.md), read as plain text, holding zero or
    more citations written as a bracketed key, such as
    "[atomic-checkpoint]"; and a reference-list JSON file
    (REFERENCES.json), a JSON object mapping each citation key to a
    JSON object of that reference's own metadata (a "title" field and
    any others).
Outputs: One line per finding, then a summary line, all printed to
    standard output.

A citation is a run of letters, digits, a hyphen, an underscore or a
period, inside square brackets, starting with a letter or a digit. A
bracketed phrase containing a space, such as an ordinary Markdown
link's own visible text, never matches this pattern and is never
treated as a citation.

Errors (exit 1): a citation key found in TEXT.md that has no matching
entry in REFERENCES.json. Warnings (exit 0, never change the exit
code): a key defined in REFERENCES.json that TEXT.md never cites. Each
key is reported once, however many times it is cited or repeated: the
`keys_used` count in the summary line is the number of distinct
citation keys found in TEXT.md, not the number of citations.

This script does not judge whether a traced source actually supports
the claim it is cited for; it only checks that the key resolves.
"""

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

sys.dont_write_bytecode = True
if sys.version_info < (3, 10):
    print(
        "citation_key_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
KEY_PATTERN = re.compile(r"\[([A-Za-z0-9][A-Za-z0-9_.-]*)\]")


def _check_readable(path: Path) -> None:
    """Refuse a symlink and a file over MAX_BYTES; shared by both loaders."""
    if path.is_symlink():
        raise OSError(f"refusing to read a symlink: {path}")
    if path.stat().st_size > MAX_BYTES:
        raise ValueError(f"{path} is over {MAX_BYTES} bytes; skipping")


def load_text(path: Path) -> str:
    """Read a body-text file as UTF-8, replacing anything that will not decode."""
    _check_readable(path)
    return path.read_text(encoding="utf-8", errors="replace")


def load_references(path: Path) -> dict[str, Any]:
    """Read and parse a reference-list JSON file; never follows a symlink."""
    _check_readable(path)
    text = path.read_text(encoding="utf-8", errors="replace")
    data = json.loads(text)
    if not isinstance(data, dict):
        raise ValueError("the reference list is not a JSON object")
    for key, value in data.items():
        if not isinstance(value, dict):
            raise ValueError(f"reference {key!r} is not a JSON object")
    return data


def find_used_keys(text: str) -> list[str]:
    """Return each distinct citation key in TEXT.md, in first-seen order."""
    seen: dict[str, None] = {}
    for match in KEY_PATTERN.finditer(text):
        seen.setdefault(match.group(1), None)
    return list(seen.keys())


def check_citations(
    text: str, references: dict[str, Any]
) -> tuple[list[str], list[str], int]:
    """Return (errors, warnings, keys_used): the bidirectional citation check."""
    used_keys = find_used_keys(text)
    used_set = set(used_keys)
    reference_keys = list(references.keys())

    errors = [
        f"undefined: citation key {key!r} is used in TEXT.md but not "
        "defined in REFERENCES.json"
        for key in used_keys
        if key not in references
    ]
    warnings = [
        f"uncited: reference key {key!r} is defined in REFERENCES.json "
        "but never used in TEXT.md"
        for key in reference_keys
        if key not in used_set
    ]
    return errors, warnings, len(used_keys)


def build_parser() -> argparse.ArgumentParser:
    """Build the argument parser."""
    parser = argparse.ArgumentParser(
        prog="citation_key_check.py",
        description=(
            "Check a body-text excerpt's citation keys against a reference "
            "list, in both directions. Writes no files."
        ),
    )
    parser.add_argument("text", metavar="TEXT", help="path to the body-text file")
    parser.add_argument(
        "references", metavar="REFERENCES", help="path to the reference-list JSON"
    )
    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:
        text = load_text(Path(args.text))
        references = load_references(Path(args.references))
        errors, warnings, keys_used = check_citations(text, references)
    except (
        OSError,
        UnicodeError,
        ValueError,
        json.JSONDecodeError,
        RecursionError,
    ) as exc:
        print(f"citation_key_check.py: error: {exc}", file=sys.stderr)
        return 2
    for message in errors:
        print(f"error {message}")
    for message in warnings:
        print(f"warning {message}")
    print(f"keys_used={keys_used} errors={len(errors)} warnings={len(warnings)}")
    return 1 if errors 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.