#!/usr/bin/env python3
"""build-result-lint — mechanical gate for the builder's BuildResult.

`BuildResult` is a newly named and shaped contract (ADR-0091 D3, SPEC
REQ-005, LLD §C2 — no prior contract existed) — the build pass's own
self-check, portable across every runtime this plugin targets. Structurally
parallel to `record-lint` and `verify-proof-lint` (same CLI shape, same
exit-code contract, same "a slot may read `UNMEASURED — <reason>`; silent
omission is not legal" rule) — see `record-lint`'s own header for the shared
family's rationale.

The one rule this linter enforces above the shared family's shape/enum/
evidence checks: **`BuildResult` never carries a ship/hold verdict.**
`selfCheck` is a self-report only (pass/fail/UNMEASURED on the builder's own
gates) — the independent ship/hold call stays `VerifyProof`-exclusive,
generator-≠-reviewer preserved structurally, not just by convention
(ADR-0091 D3). A `verdict:` line, or a `selfCheck:` value that reads as a
ship/hold call, is flagged as a contract violation, not a style nit.

Checks:
  1. Required lines present, in any order: surface / orientationRef /
     filesChanged / gatesRun / evidence / selfCheck / openIssues.
  2. `selfCheck` ∈ {pass, fail, UNMEASURED}; `UNMEASURED` carries a
     non-empty `— <reason>` clause.
  3. `selfCheck`'s leading token is never `ship`/`hold` (case-insensitive) —
     the structural generator-≠-reviewer guarantee. Prose that merely
     *mentions* shipping/holding (e.g. "pass — all gates green (NOT a
     ship/hold verdict)") is untouched; only the slot's own verdict-shaped
     head is checked, matching how the enum check above reads every other
     slot.
  4. No `verdict:` line appears anywhere in the record — that field name is
     `VerifyProof`-exclusive; its presence here is the same violation class
     as (3), caught even if `selfCheck` itself stayed clean.
  5. `surface` / `orientationRef` / `filesChanged` / `gatesRun` / `evidence`
     are non-empty.
  6. `openIssues` is non-empty; the literal value `none` is legal.

Usage:
  build-result-lint <file>       # lint a file containing a BuildResult
  build-result-lint -            # lint stdin
  build-result-lint selftest     # embedded fixtures; exit 0 iff all pass
Exit 1 on findings, 0 clean. Stdlib only (Python 3.8+).
"""
import re
import sys

REQUIRED = [
    'surface', 'orientationRef', 'filesChanged', 'gatesRun', 'evidence',
    'selfCheck', 'openIssues',
]
NON_EMPTY = ['surface', 'orientationRef', 'filesChanged', 'gatesRun', 'evidence']
SELF_CHECK_ENUM = {'pass', 'fail', 'UNMEASURED'}
LINE_RE = re.compile(r'^\s*([A-Za-z][A-Za-z0-9]*):\s*(.*)$')


def _head_and_reason(value):
    m = re.match(r'^(.*?)(?:\s+[—–-]\s+(.*))?$', value.strip(), re.S)
    if not m:
        return value.strip(), None
    return m.group(1).strip(), (m.group(2).strip() if m.group(2) else None)


def lint(text):
    findings = []
    lines = {}
    has_verdict_line = False
    for raw in text.splitlines():
        m = LINE_RE.match(raw)
        if not m:
            continue
        label = m.group(1)
        if label == 'verdict':
            has_verdict_line = True
        if label in REQUIRED and label not in lines:
            lines[label] = m.group(2).strip()

    for label in REQUIRED:
        if label not in lines:
            findings.append(f"missing line: '{label}:'")

    for label in NON_EMPTY:
        if label in lines and not lines[label]:
            findings.append(f"{label}: empty")

    if 'openIssues' in lines and not lines['openIssues']:
        findings.append("openIssues: empty (use 'none' if there are none)")

    if 'selfCheck' in lines:
        value = lines['selfCheck']
        head, reason = _head_and_reason(value)
        if head.lower() in ('ship', 'hold'):
            findings.append(
                f"selfCheck: '{head}' reads as a ship/hold verdict — BuildResult has NO verdict "
                "field; the ship/hold call stays VerifyProof-exclusive (ADR-0091 D3)"
            )
        elif head not in SELF_CHECK_ENUM:
            findings.append(f"selfCheck: '{head}' not in {sorted(SELF_CHECK_ENUM)}")
        if head == 'UNMEASURED' and not reason:
            findings.append("selfCheck: 'UNMEASURED' with no '— <reason>' clause")

    if has_verdict_line:
        findings.append(
            "'verdict:' line present — BuildResult has no verdict field; a ship/hold call "
            "belongs in VerifyProof only, never self-reported by the builder"
        )

    return findings


GOOD = """\
BuildResult
surface:         settings-screen
orientationRef:  app-planning#2026-08-24T10:03Z
filesChanged:    app/screens/settings.html, app/screens/settings.css
gatesRun:        adia-lint: clean | validate_schema: pass | check_anti_patterns: pass
evidence:        adia-probe run at http://localhost:5173/settings, screenshot ./qa/settings-01.png
selfCheck:       pass — all self-run gates green (NOT a ship/hold verdict)
openIssues:      none
"""

GOOD_UNMEASURED = """\
BuildResult
surface:         settings-screen
orientationRef:  app-planning#2026-08-24T10:03Z
filesChanged:    app/screens/settings.html
gatesRun:        adia-lint: clean
evidence:        no dev server available this pass
selfCheck:       UNMEASURED — no dev server available to probe
openIssues:      dev server needed before the next pass
"""

BAD = """\
BuildResult
surface:
orientationRef:  app-planning#2026-08-24T10:03Z
filesChanged:    app/screens/settings.html
gatesRun:        adia-lint: clean
evidence:        adia-probe run at http://localhost:5173/settings
selfCheck:       ship — looks good to me
openIssues:
"""

BAD_VERDICT_LINE = GOOD + """verdict:         ship
"""

BAD_UNMEASURED_NO_REASON = """\
BuildResult
surface:         settings-screen
orientationRef:  app-planning#2026-08-24T10:03Z
filesChanged:    app/screens/settings.html
gatesRun:        adia-lint: clean
evidence:        adia-probe run
selfCheck:       UNMEASURED
openIssues:      none
"""


def _selftest():
    import io

    fails = []
    if lint(GOOD):
        fails.append(f"good fixture flagged: {lint(GOOD)}")
    if lint(GOOD_UNMEASURED):
        fails.append(f"good-unmeasured fixture flagged: {lint(GOOD_UNMEASURED)}")

    bad = lint(BAD)
    expect = [
        "surface: empty",
        "selfCheck: 'ship' reads as a ship/hold verdict",
        "openIssues: empty",
    ]
    for e in expect:
        if not any(e in f for f in bad):
            fails.append(f"bad fixture missed: {e} (got {bad})")

    bad_verdict = lint(BAD_VERDICT_LINE)
    if not any("'verdict:' line present" in f for f in bad_verdict):
        fails.append(f"bad-verdict-line fixture missed the verdict-line finding (got {bad_verdict})")

    bad_unmeasured = lint(BAD_UNMEASURED_NO_REASON)
    if not any("'UNMEASURED' with no" in f for f in bad_unmeasured):
        fails.append(f"bad-unmeasured fixture missed the no-reason finding (got {bad_unmeasured})")

    for flag in ('-h', '--help'):
        saved_out = sys.stdout
        sys.stdout = io.StringIO()
        try:
            rc_help = main(['build-result-lint', flag])
            help_out = sys.stdout.getvalue()
        finally:
            sys.stdout = saved_out
        if rc_help != 0:
            fails.append(f'{flag} did not exit 0')
        if not help_out.startswith('usage:'):
            fails.append(f'{flag} did not print usage')

    if fails:
        print('selftest FAIL: ' + ' | '.join(fails), file=sys.stderr)
        return 1
    print(
        f'selftest OK — good fixtures clean, bad fixture {len(bad)} findings, '
        f'verdict-line/UNMEASURED-reason checks hold, -h/--help contract holds'
    )
    return 0


_USAGE = 'usage: build-result-lint <file>  |  build-result-lint -  |  build-result-lint selftest'


def main(argv):
    if len(argv) > 1 and argv[1] == 'selftest':
        return _selftest()
    if len(argv) > 1 and argv[1] in ('-h', '--help'):
        print(_USAGE)
        return 0
    text = sys.stdin.read() if (len(argv) < 2 or argv[1] == '-') else open(argv[1], encoding='utf-8').read()
    findings = lint(text)
    if findings:
        print(f'build-result-lint · {len(findings)} finding(s):')
        for f in findings:
            print(f'  {f}')
        print('Contract: LLD §C2 — BuildResult (docs/ops/lld/lld-ui-architect-cross-harness-portability.md).')
        return 1
    print('build-result-lint · clean')
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
