Viewing files at commit 19b295c14414Commit detailsView latest

Browse files
scripts/oakbench/conformance_gate.py 174 lines · 9.3 KB · python
1
"""Conformance gate: diagnostic conformance rows vs an explicit expected-failures list.
2
3
Semantics (strict xfail, schema 2):
4
- every expected-failure entry names its failing legs AND an error pattern per
5
  leg (``re.fullmatch`` against that leg's detail; legacy v1 scenarios use the
6
  pseudo-leg ``behaviour`` matched against the row's ``failure``). A listed
7
  behaviour fails as expected only when the failing-leg set is identical and
8
  every detail matches its pattern. Any other failure - a new leg, a changed
9
  error (EEXIST -> ``extra=['../../etc/evil']``, a rejection -> a panic) - is
10
  a REGRESSION;
11
- a listed behaviour that passes on a profile the entry applies to is FIXED:
12
  not a regression, but publish_gate refuses until the list is updated;
13
- entries may be scoped to filesystem profiles (``fs_profiles``); on other
14
  profiles they do not apply, so a pass there is just a pass (never FIXED) and
15
  a failure there is a regression;
16
- ``outcome: refused`` marks typed policy refusals (a product decision, e.g.
17
  control characters in names) and is reported separately from bugs;
18
- coverage: for a recorded profile, any skip not in its ``expected_skips`` is
19
  a coverage loss (UNMEASURED) - a behaviour that used to pass or xfail must
20
  not silently turn into a skip. Unrecorded profiles accept capability/gated
21
  skips and say so;
22
- harness errors, reader-unmeasured legs and required behaviours with no row
23
  make the result UNMEASURED.
24
25
Only the gated subject (Oak) is judged; Git rows are the reference arm.
26
"""
27
from __future__ import annotations
28
29
import json
30
from pathlib import Path
31
import re
32
33
ROOT = Path(__file__).resolve().parents[2]
34
EXPECTED_FAILURES = ROOT / "config" / "conformance_expected_failures.json"
35
SCHEMA_VERSION = 2
36
TICKET = re.compile(r"^fb-\d+$")
37
OUTCOMES = ("bug", "refused")
38
CAPABILITY_SKIP_PREFIXES = ("filesystem capability unavailable", "gated:", "gated behaviour")
39
40
41
def load_expected(path: Path = EXPECTED_FAILURES) -> dict:
42
    data = json.loads(Path(path).read_text())
43
    if data.get("schema_version") != SCHEMA_VERSION or not isinstance(data.get("entries"), list):
44
        raise ValueError("conformance expected-failures: unsupported schema")
45
    entries = {}
46
    for entry in data["entries"]:
47
        behaviour = entry.get("behaviour")
48
        if not behaviour or behaviour in entries:
49
            raise ValueError(f"conformance expected-failures: bad or duplicate behaviour {behaviour!r}")
50
        if not TICKET.match(str(entry.get("ticket", ""))):
51
            raise ValueError(f"conformance expected-failures: {behaviour} needs a filed ticket (fb-N)")
52
        if entry.get("outcome", "bug") not in OUTCOMES:
53
            raise ValueError(f"conformance expected-failures: {behaviour} outcome must be one of {OUTCOMES}")
54
        legs = entry.get("failed_legs")
55
        if not isinstance(legs, dict) or not legs:
56
            raise ValueError(f"conformance expected-failures: {behaviour} needs failed_legs {{leg: pattern}}")
57
        compiled = {}
58
        for leg, pattern in legs.items():
59
            if not isinstance(pattern, str) or not pattern:
60
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} needs an error pattern")
61
            compiled[leg] = re.compile(pattern, re.S)
62
        profiles = entry.get("fs_profiles")
63
        if profiles is not None and (not isinstance(profiles, list) or not profiles):
64
            raise ValueError(f"conformance expected-failures: {behaviour} fs_profiles must be a non-empty list")
65
        entries[behaviour] = {**entry, "outcome": entry.get("outcome", "bug"), "patterns": compiled}
66
    coverage = data.get("coverage") or {}
67
    if not isinstance(coverage, dict):
68
        raise ValueError("conformance expected-failures: coverage must map profile -> {expected_skips}")
69
    return {"subject": data.get("subject", "oak"), "entries": entries, "coverage": coverage}
70
71
72
def row_failed_legs(row: dict) -> dict:
73
    """{failed leg: detail} for a failing row (legacy rows: {'behaviour': failure})."""
74
    legs = row.get("legs")
75
    if isinstance(legs, dict):
76
        return {name: str(leg.get("detail") or "") for name, leg in legs.items()
77
                if leg.get("status") in ("fail", "error")}
78
    return {"behaviour": str(row.get("failure") or "")} if row.get("returncode") == 1 else {}
79
80
81
def entry_applies(entry: dict, profile: str | None) -> bool:
82
    profiles = entry.get("fs_profiles")
83
    return profiles is None or profile in profiles
84
85
86
def mismatch(entry: dict, failed: dict) -> str | None:
87
    """Why a failing row does not match its expected failure, or None."""
88
    expected = set(entry["patterns"])
89
    if set(failed) != expected:
90
        return f"expected legs {sorted(expected)}, observed {sorted(failed)}"
91
    for leg, pattern in entry["patterns"].items():
92
        if not pattern.fullmatch(failed[leg]):
93
            return f"leg {leg} error changed: {failed[leg][:200]!r} does not match {pattern.pattern!r}"
94
    return None
95
96
97
def evaluate(rows: list, expected: dict, required=None) -> dict:
98
    subject = expected["subject"]
99
    entries = expected["entries"]
100
    result = {"subject": subject, "passed": [], "expected_failures": [], "refused": [], "fixed": [],
101
              "regressions": [], "unmeasured": [], "skipped": [], "git_reference_failures": [],
102
              "differential_differs": [], "profiles": [], "notes": []}
103
    seen = set()
104
    subject_rows = [row for row in rows if row.get("evidence_class") == "diagnostic_conformance"]
105
    profiles = sorted({str(row.get("fs_profile")) for row in subject_rows
106
                       if row.get("subject") == subject and row.get("fs_profile")})
107
    result["profiles"] = profiles
108
    if len(profiles) > 1:
109
        result["unmeasured"].append(f"rows mix filesystem profiles {profiles}; gate one host at a time")
110
        result["verdict"] = "UNMEASURED"
111
        return result
112
    profile = profiles[0] if len(profiles) == 1 else None
113
    recorded = expected["coverage"].get(profile) if profile else None
114
    if profile and recorded is None:
115
        result["notes"].append(f"filesystem profile {profile} has no recorded coverage; capability skips accepted")
116
    expected_skips = set((recorded or {}).get("expected_skips", []))
117
    for row in subject_rows:
118
        behaviour = row.get("scenario")
119
        code = row.get("returncode")
120
        if row.get("subject") != subject:
121
            if row.get("subject") == "git" and code == 1:
122
                result["git_reference_failures"].append(f"{behaviour}: {row.get('failure')}")
123
            continue
124
        seen.add(behaviour)
125
        entry = entries.get(behaviour)
126
        if entry is not None and not entry_applies(entry, profile):
127
            entry = None
128
        differential = str(row.get("git_differential") or "")
129
        if differential.startswith("differs"):
130
            result["differential_differs"].append(f"{behaviour}: {differential}")
131
        if code == 0:
132
            if entry:
133
                result["fixed"].append(f"{behaviour}: now passes; remove {entry['ticket']} from the expected-failures list")
134
            else:
135
                result["passed"].append(behaviour)
136
        elif code == 1:
137
            failed = row_failed_legs(row)
138
            if entry is None:
139
                result["regressions"].append(f"{behaviour}: {row.get('failure')}")
140
                continue
141
            why = mismatch(entry, failed)
142
            if why:
143
                result["regressions"].append(f"{behaviour}: known failure ({entry['ticket']}) changed: {why}")
144
            else:
145
                key = "refused" if entry["outcome"] == "refused" else "expected_failures"
146
                result[key].append(f"{behaviour}: {entry['ticket']}")
147
        elif code == 77 and str(row.get("skip_reason", "")).startswith(CAPABILITY_SKIP_PREFIXES):
148
            if recorded is not None and behaviour not in expected_skips:
149
                result["unmeasured"].append(f"{behaviour}: coverage loss on {profile}: skipped "
150
                                            f"({row.get('skip_reason')}) but not an expected skip")
151
            else:
152
                result["skipped"].append(f"{behaviour}: {row.get('skip_reason')}")
153
        else:
154
            result["unmeasured"].append(f"{behaviour}: {row.get('failure') or row.get('skip_reason') or code}")
155
    for behaviour in sorted(set(required or ()) - seen):
156
        result["unmeasured"].append(f"{behaviour}: no {subject} conformance row")
157
    if not seen:
158
        result["unmeasured"].append(f"no {subject} conformance rows")
159
    result["verdict"] = ("REGRESSED" if result["regressions"] else
160
                         "UNMEASURED" if result["unmeasured"] else "PASS")
161
    return result
162
163
164
def summary_lines(result: dict) -> list:
165
    lines = [f"conformance ({result['subject']}, {','.join(result['profiles']) or 'no profile'}): "
166
             f"{result['verdict']} - {len(result['passed'])} pass, {len(result['expected_failures'])} expected-fail, "
167
             f"{len(result['refused'])} policy-refused, {len(result['fixed'])} fixed, "
168
             f"{len(result['regressions'])} regression(s), {len(result['unmeasured'])} unmeasured, "
169
             f"{len(result['skipped'])} capability/gated skip(s)"]
170
    for key, label in (("regressions", "REGRESSION"), ("fixed", "FIXED"), ("unmeasured", "UNMEASURED"),
171
                       ("expected_failures", "XFAIL"), ("refused", "REFUSED"), ("differential_differs", "GIT-DIFF"),
172
                       ("git_reference_failures", "GIT-REF"), ("skipped", "SKIP"), ("notes", "NOTE")):
173
        lines += [f"  {label} {item}" for item in result[key]]
174
    return lines