#!/usr/bin/env python3
"""subjects.py - does a merge commit's subject describe the diff under it?

Why this exists (wake 22). `operon github merge` squashes, and a squash
commit's SUBJECT IS THE PULL REQUEST TITLE. A title written when the pull
request was opened, and then overtaken by a change made during review, becomes
permanent history at the moment I merge. Nothing in the registry's CI reads the
title, and nothing re-reads it at merge time except me.

The checkable form of the defect: a subject that names a job id, on a diff that
neither claims that job nor touches that job's file. That is the exact shape of
04d1d77 (#44, Linear, merged by me at wake 15): "claims eng.triage-issues with
its boundary stated" over a diff whose jobs array is [], because the claim came
out during review in the second commit of the same pull request.

A SUBJECT CAN NAME A JOB IN ORDER TO DECLINE IT, and until wake f4f33561 this
script could not read that (#342, datadog v5: "the it.triage-alerts cell was
empty for want of reading a page ... and the cell stays empty", over a diff that
deliberately adds no claim). The MISS it printed was literally true and
substantively backwards: for a DECLINING subject, a diff that does not claim the
job is the subject being RIGHT. So polarity is read per job id now, and the
inverse defect becomes reachable for the first time:

    CLAIMS   + the diff backs it     -> OK    CLAIMS   + untouched        -> MISS
    DECLINES + cell empty at head    -> OK    DECLINES + cell full at head -> MISS

The second MISS is the worse of the two and nothing in this repository was
checking for it: a subject telling main's permanent log that a cell stays empty,
over a diff that fills it.

Polarity is read from the job id's OWN CLAUSE, never from the whole subject,
because a subject may claim one job and decline another in one line. A clause
carrying both a declining marker and an explicit claiming verb on that same id
is AMBIGUOUS: it is printed, named, and NOT scored, because a gate that guesses
polarity silently is worse than one that says "read this by eye".

AND CLAUSE-SCOPING ALONE MISREADS A DECLINE THAT REFERS TO THE JOB BY PRONOUN
(wake bcab0261, #343). "...the registry carries fin.run-payroll unclaimed...;
...and the cell stays empty" names the job in clause 1 and declines it in clause
2, where "the cell" carries no job id at all. Clause-scoped polarity saw no
marker beside the id and read CLAIMS, so the gate printed MISS over a subject
that was true -- the SAME substantive error as #342, one wake after I fixed
#342's instance of it. The two readings are in tension and each has a false
verdict: whole-subject polarity makes "claims X; Y stays empty" decline X
(false OK, the dangerous direction), clause-only polarity makes #343 a MISS
(false alarm). What separates them is HOW MANY ids the subject names. So:

    a declining marker in an ORPHAN CLAUSE (one naming no job id at all)
    attaches to the subject's job id when the subject names EXACTLY ONE,
    because there is nothing else "the cell" can mean; when the subject
    names two or more it is AMBIGUOUS, never guessed.

An explicit claiming verb on the id in its own clause BEATS an orphan decline,
and the verdict there is CLAIMS, not AMBIGUOUS: CLAIMS is the strict direction,
and the first draft of this branch returned AMBIGUOUS and so silently
downgraded a MISS this script already printed correctly. Rule 12 caught it.
AMBIGUOUS is for the genuine self-contradiction only -- a declining marker and
a claiming verb on the same id in the same clause.

"unclaimed listing" is a term of art here (the VENDOR has not claimed the
entry) and is deliberately not a declining marker; rule 5 holds 04d1d77 to MISS
to keep it that way.

It reports on history and, with --pr, on an open head before the door is
knocked on.

CONTROL, printed first and always. Twelve rules the instrument must pass before
a word below it is believed; the strongest are a single diff read under two
mirror-image subjects, where all four verdicts must invert, and rule 12, where
an invented claim must not be rescued by a decline it does not own. If any rule
fails, every line below it is void and it stops.

    python3 tools/subjects.py [--clone DIR]
    python3 tools/subjects.py --pr 69 --title "..." [--clone DIR]
"""
import argparse
import json
import re
import subprocess
import sys

JOBID = re.compile(r"\b(?:eng|fin|hr|it|mkt|proc|sales|scm|cs|legal)\.[a-z0-9-]+\b")

# A clause saying this job is NOT claimed. Every one of these is about the JOB
# CELL. "unclaimed" is absent on purpose: "as an unclaimed listing" appears in
# a dozen merged subjects and is about the vendor, not about a job.
DECLINE = [
    "not claimed", "no claim", "stays empty", "stay empty", "staying empty",
    "remains empty", "remain empty", "was empty", "is empty", "left empty",
    "leaves empty", "declined", "declines", "declining", "not filed",
    "unfilled", "withdrawn", "comes out", "came out",
]

# Verdicts. OK and MISS score; AMBIGUOUS deliberately does not.
OK, MISS, AMBIGUOUS = "OK", "MISS", "AMBIGUOUS"


def git(clone, *args):
    return subprocess.run(
        ["git", "-C", clone] + list(args), capture_output=True, text=True
    ).stdout


def clauses_of(subject):
    """The subject split into clauses, at ';' and the em/en dash, which is
    where this colony's subjects actually turn."""
    return re.split(r"[;—–]", subject)


def clause_of(subject, jid):
    """The clause of the subject that carries this job id.

    Polarity must never be read from the whole subject: "claims X; Y stays
    empty" would then read as declining BOTH.
    """
    for p in clauses_of(subject):
        if jid in p:
            return p
    return subject


def orphan_decline(subject):
    """Does some clause naming NO job id carry a declining marker?

    This is where #343 put its decline: "...fin.run-payroll unclaimed...; ...and
    the cell stays empty". Such a marker has no id beside it and so belongs to
    whatever the subject's single id is -- and to nothing determinate at all
    when the subject names two.
    """
    for p in clauses_of(subject):
        low = p.lower()
        if JOBID.search(p):
            continue
        if any(m in low for m in DECLINE):
            return True
    return False


def claiming_verb(clause, jid):
    """An explicit claiming verb bound to THIS id in this clause."""
    esc = re.escape(jid.lower())
    return re.search(
        r"(claims|adds|files|fills)\s+(the\s+)?%s|%s\s+(is|was)\s+(claimed|filed|added)"
        % (esc, esc), clause.lower())


def polarity(subject, jid):
    """CLAIMS or DECLINES or AMBIGUOUS, for this job id in this subject.

    CLAIMS is the default, which is what 26 of the 27 merged subjects on main
    assert and why this script read them correctly before polarity existed.
    """
    clause = clause_of(subject, jid).lower()
    claiming = claiming_verb(clause, jid)
    if any(m in clause for m in DECLINE):
        # An explicit claiming verb bound to THIS id, in the same clause as a
        # declining marker, means the subject says both things and I will not
        # guess.
        return AMBIGUOUS if claiming else "DECLINES"
    # No marker beside the id. A decline may still be here, referring to the
    # job by pronoun from a clause of its own -- but only an unambiguous
    # reference is read, and the count of ids in the subject is what decides.
    if orphan_decline(subject):
        # An explicit claiming verb on THIS id beats a pronoun elsewhere, and
        # the answer is CLAIMS rather than AMBIGUOUS on purpose: CLAIMS is the
        # strict direction, and writing this branch as AMBIGUOUS would have
        # downgraded a MISS this script already printed correctly (rule 12) to
        # a verdict that is not scored at all. A gate must not lose a MISS to
        # politeness.
        if claiming:
            return "CLAIMS"
        if len(set(JOBID.findall(subject))) == 1:
            return "DECLINES"
        return AMBIGUOUS              # "the cell" could be either id's
    return "CLAIMS"


def verdict(clone, sha, subject, base=None):
    """Return (job id -> OK/MISS/AMBIGUOUS) for every job id the subject names.

    For a CLAIMING subject, OK means the diff backs it up in one of three ways:
    it adds a structured claim on that job, it creates that job's file, or it
    edits that job's file.

    For a DECLINING subject the test is a different one, and getting it right
    took two tries. "The cell stays empty" is a claim about the STATE AT THE
    HEAD, not about the diff: asking only whether a claim APPEARED read OK for
    "the eng.investigate-incidents cell stays empty" over #342, whose datadog
    jobs[] holds exactly that claim at the base and at the head. So the test is
    whether the changed entries' jobs[] hold the job AT THE HEAD. A declining
    revision is expected to edit the profile prose about the job and may touch
    the job's own taxonomy file; neither of those fills a cell, because the
    cell is the entry's own jobs[] and nothing else.

    THE LIMIT, and it is strict in the safe direction: the head state is a
    UNION over every changed entry. A diff that claims a job in one entry and
    declines the same job in another would read MISS on the declining clause.
    No pull request has had that shape; when one does, the MISS sends me to
    read the title by eye, which is the right place to land.
    """
    ids = sorted({m.group(0) for m in JOBID.finditer(subject)})
    if not ids:
        return {}
    if base:
        diff = git(clone, "diff", "--unified=0", f"{base}...{sha}")
        files = git(clone, "diff", "--name-only", f"{base}...{sha}").split()
    else:
        diff = git(clone, "show", sha, "--format=", "--unified=0")
        files = git(clone, "show", sha, "--format=", "--name-only").split()
    added = "\n".join(l for l in diff.split("\n") if l.startswith("+"))
    edited = edited_claims(clone, sha, files, base)
    at_head = claims_at_head(clone, sha, files)
    out = {}
    for jid in ids:
        pol = polarity(subject, jid)
        if pol == AMBIGUOUS:
            out[jid] = AMBIGUOUS
            continue
        if pol == "DECLINES":
            out[jid] = MISS if jid in at_head else OK
            continue
        claimed = re.search(r'"job"\s*:\s*"%s"' % re.escape(jid), added) is not None
        touched = any(f.endswith("/" + jid + ".json") for f in files)
        out[jid] = OK if (claimed or touched or jid in edited) else MISS
    return out


def claims_at_head(clone, sha, files):
    """Every job id the CHANGED entries' jobs[] hold at the head.

    Parsed, not grepped: a claim can sit on a reformatted line, and a declining
    subject's whole meaning turns on this set not holding the job it names.
    """
    out = set()
    for f in files:
        if not (f.startswith("registry/tools/") or f.startswith("registry/agents/")):
            continue
        if not f.endswith(".json"):
            continue
        out |= set(jobs_of(git(clone, "show", f"{sha}:{f}")))
    return out


def edited_claims(clone, sha, files, base=None):
    """Job ids whose claim OBJECT differs between base and head in a changed
    entry file. Wake 97c00daa: #284 filled `sources` on an existing claim and
    the only added lines were the URLs, so the job id sat on a context line
    and the subject read MISS although the diff was exactly what it said.
    An edit to any field of a claim touches that job."""
    parent = base if base else sha + "^"
    out = set()
    for f in files:
        if not (f.startswith("registry/tools/") or f.startswith("registry/agents/")):
            continue
        if not f.endswith(".json"):
            continue
        before = jobs_of(git(clone, "show", f"{parent}:{f}"))
        after = jobs_of(git(clone, "show", f"{sha}:{f}"))
        for jid in set(before) | set(after):
            if before.get(jid) != after.get(jid):
                out.add(jid)
    return out


def jobs_of(text):
    try:
        doc = json.loads(text)
    except (ValueError, TypeError):
        return {}
    jobs = doc.get("jobs") if isinstance(doc, dict) else None
    if not isinstance(jobs, list):
        return {}
    return {j.get("job"): json.dumps(j, sort_keys=True) for j in jobs if isinstance(j, dict) and j.get("job")}


def control(clone):
    """Falsify the instrument before believing a word of it.

    Twelve rules. 1 and 2 are the original pair. 3 and 4 are the declining
    polarity in both directions. 5 pins "unclaimed listing" as NOT a declining
    marker, which is the one way this change could have broken the corpus it
    already read correctly. 6 and 7 are the mirror pair: ONE diff, two
    subjects, four verdicts, every one inverted.
    """
    fails = []
    # A merge whose subject names a job it really does add, and which adds that
    # claim to two entries' jobs[] (so rules 4 and 6 can lean on it).
    truthful = "baad98d"
    claimed_id = "eng.retrieve-reference-context"
    absent_id = "fin.supply-market-data"
    if not git(clone, "cat-file", "-t", truthful).strip():
        print("CONTROL SKIPPED: %s not in this clone" % truthful)
        return True

    def check(n, what, got, want):
        if got != want:
            fails.append("%d (%s): wanted %r, got %r" % (n, what, want, got))

    # 1. A true claiming subject reads OK.
    v = verdict(clone, truthful, git(clone, "log", "-1", "--format=%s", truthful))
    check(1, "a true claiming subject", v.get(claimed_id), OK)
    # 2. An invented claiming subject over the same diff reads MISS.
    v = verdict(clone, truthful, "feat(registry): claims fin.file-and-remit-tax-returns")
    check(2, "an invented claiming subject", v.get("fin.file-and-remit-tax-returns"), MISS)
    # 3. A declining subject over a diff that adds no such claim reads OK.
    #    This is #342's own shape, and it read MISS before wake f4f33561.
    v = verdict(clone, truthful,
                "datadog v5: the %s cell was empty for want of reading a page; "
                "the page is read and the cell stays empty" % absent_id)
    check(3, "a true declining subject", v.get(absent_id), OK)
    # 4. THE INVERSE DEFECT IS REACHABLE: a subject saying the cell stays empty,
    #    over a diff that fills it, reads MISS. Nothing checked this before.
    v = verdict(clone, truthful,
                "context7 v2: the %s cell stays empty" % claimed_id)
    check(4, "a declining subject over a diff that fills the cell",
          v.get(claimed_id), MISS)
    # 5. "unclaimed listing" is NOT a declining marker: the one real MISS on
    #    main must stay a MISS, not become an OK or an AMBIGUOUS.
    linear = "04d1d77"
    if git(clone, "cat-file", "-t", linear).strip():
        v = verdict(clone, linear, git(clone, "log", "-1", "--format=%s", linear))
        check(5, "'unclaimed listing' is not a decline", v.get("eng.triage-issues"), MISS)
    # 6 and 7. The mirror pair over ONE diff. Polarity is clause-scoped, so
    #    swapping which job sits in which clause must invert all four verdicts.
    both = "claims %s; the %s cell stays empty" % (claimed_id, absent_id)
    v = verdict(clone, truthful, both)
    check(6, "mirror A, the claimed id", v.get(claimed_id), OK)
    check(6, "mirror A, the declined id", v.get(absent_id), OK)
    swapped = "the %s cell stays empty; claims %s" % (claimed_id, absent_id)
    v = verdict(clone, truthful, swapped)
    check(7, "mirror B, the declined id now wrong", v.get(claimed_id), MISS)
    check(7, "mirror B, the claimed id now wrong", v.get(absent_id), MISS)
    # 8. A cell DECLINED but already FULL at the base and the head reads MISS.
    #    This rule exists because the first draft got it wrong: it tested
    #    whether a claim APPEARED, so a subject calling a long-standing claim
    #    empty read OK. baad98d's deepwiki and context7 both end holding the
    #    claim, so a subject declining it must MISS whichever way it arrived.
    v = verdict(clone, truthful, "the %s cell remains empty" % claimed_id)
    check(8, "declining a cell that is full at the head", v.get(claimed_id), MISS)
    # 9 to 12 are wake bcab0261's: the decline that refers to its job by
    # pronoun from a clause of its own. #343's real title is rule 9 verbatim,
    # because a regression rule written from the paraphrase of a title is a
    # rule about my paraphrase.
    pronoun = ("rippling v3: the entry said everything else was out of scope by "
               "function while the registry carries %s unclaimed and the entry's "
               "own first sentence names payroll; the page is read, three of four "
               "elements have the vendor's words in part and none in full, and "
               "the cell stays empty" % absent_id)
    v = verdict(clone, truthful, pronoun)
    check(9, "#343: a decline by pronoun in a clause of its own", v.get(absent_id), OK)
    # 10. THE SAME SHAPE OVER A DIFF THAT FILLS THE CELL MUST STILL MISS.
    #     Rule 9 alone would pass an implementation that returned OK for every
    #     orphan decline without ever reading the head.
    v = verdict(clone, truthful, pronoun.replace(absent_id, claimed_id))
    check(10, "a decline by pronoun over a diff that fills the cell",
          v.get(claimed_id), MISS)
    # 11. THE DANGEROUS DIRECTION STAYS CLOSED. Two ids, an explicit claim on
    #     one, and an orphan decline: "the cell" is not determinate, so the
    #     claimed id must NOT be read as declined (which would turn its MISS
    #     into an OK). Here the claim is true, so OK either way is uninformative
    #     -- the mirror below is the one that bites.
    two = ("claims %s and leaves %s alone; and the cell stays empty"
           % (claimed_id, absent_id))
    v = verdict(clone, truthful, two)
    check(11, "two ids and an orphan decline: the untouched id is not guessed",
          v.get(absent_id), AMBIGUOUS)
    # 12. The same two-id subject with an INVENTED claim: whole-subject polarity
    #     would read the orphan decline onto it and print OK over a subject that
    #     claims a job the diff never touches. It must stay MISS.
    invented = "fin.file-and-remit-tax-returns"
    two_bad = ("claims %s and %s; and the cell stays empty" % (invented, absent_id))
    v = verdict(clone, truthful, two_bad)
    check(12, "an invented claim cannot be rescued by an orphan decline",
          v.get(invented), MISS)

    if fails:
        print("CONTROL FAILED, %d rule(s):" % len(fails))
        for f in fails:
            print("  rule " + f)
        print("EVERY LINE BELOW IS VOID")
        return False
    print("CONTROL: 12 rules hold. A claiming subject and a declining one are")
    print("scored by opposite tests, read from each job id's own clause, and one")
    print("diff under two mirror subjects inverts all four verdicts.")
    return True


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--clone", default="/tmp/lintel-queue-clone")
    ap.add_argument("--pr", type=int, help="check an open head instead of history")
    ap.add_argument("--title", help="the title that would become the subject")
    ap.add_argument("--base", default="origin/main")
    args = ap.parse_args()

    if not control(args.clone):
        sys.exit(2)
    print()

    if args.pr:
        if not args.title:
            sys.exit("--pr needs --title (the subject the squash would carry)")
        ref = "pr%d" % args.pr
        subprocess.run(
            ["git", "-C", args.clone, "fetch", "-q", "origin",
             "pull/%d/head:%s" % (args.pr, ref)],
            capture_output=True,
        )
        v = verdict(args.clone, ref, args.title, base=args.base)
        if not v:
            print("PR #%d: the title names no job id; nothing to check here" % args.pr)
            return
        for jid, got in sorted(v.items()):
            print("%-9s #%d %-42s (subject %s)"
                  % (got, args.pr, jid, polarity(args.title, jid)))
        for jid, got in sorted(v.items()):
            if got == MISS and polarity(args.title, jid) == "DECLINES":
                print("\nThe subject says the %s cell stays empty and the diff" % jid)
                print("fills it. A squash merge makes that permanent and no door can")
                print("edit it afterwards.")
            elif got == MISS:
                print("\nThe subject names %s, a job the diff does not touch. A" % jid)
                print("squash merge makes that permanent and no door can edit it")
                print("afterwards.")
            elif got == AMBIGUOUS:
                print("\nThe clause carrying %s both claims and declines it. NOT" % jid)
                print("SCORED: read this title by eye against the diff.")
        return

    rows = []
    log = git(args.clone, "log", "--format=%H\x01%s", args.base).strip().split("\n")
    for line in log:
        if "\x01" not in line:
            continue
        sha, subject = line.split("\x01", 1)
        for jid, got in sorted(verdict(args.clone, sha, subject).items()):
            rows.append((sha[:7], jid, got, subject))
    for sha, jid, got, subject in rows:
        print("%-9s %s %-42s | %s" % (got, sha, jid, subject[:70]))
    misses = [r for r in rows if r[2] == MISS]
    amb = [r for r in rows if r[2] == AMBIGUOUS]
    decl = [r for r in rows if polarity(r[3], r[1]) == "DECLINES"]
    print("\n%d merged subjects name a job id: %d MISS, %d AMBIGUOUS (not scored)."
          % (len(rows), len(misses), len(amb)))
    print("%d of them DECLINE the job they name; the rest claim it." % len(decl))


if __name__ == "__main__":
    main()
