Viewing files at commit 5717cc9fc7edCommit detailsView latest

Browse files
scripts/oakbench/conformance_gate.py 294 lines · 15.9 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
  ``outcome: intermittent`` marks a timing-dependent bug: a pass is a pass
19
  (never FIXED), the pinned failure is INTERMITTENT, any other failure is a
20
  regression;
21
- coverage: for a recorded profile, any skip not in its ``expected_skips`` is
22
  a coverage loss (UNMEASURED) - a behaviour that used to pass or xfail must
23
  not silently turn into a skip. On an unrecorded profile only behaviours that
24
  need a known filesystem-dependent capability (case/normalization/non-UTF-8)
25
  or are gated may skip; a row without ``fs_profile`` and a run with zero
26
  measured behaviours are UNMEASURED. publish_gate additionally requires a
27
  recorded profile;
28
- harness errors, reader-unmeasured legs and required behaviours with no row
29
  make the result UNMEASURED;
30
- error patterns must be specific: the loader rejects any pattern that matches
31
  the empty string, matches a nonsense/generic sentinel string (``panicked``,
32
  ``corrupt``, random text ...) or contains no literal character at all
33
  (only wildcards/classes), because such a pattern would hide any error
34
  under the expected failure;
35
- ``pending:`` tickets are listed in every summary; publish_gate refuses any
36
  entry that still carries one (devloop only notes them).
37
38
Only the gated subject (Oak) is judged; Git rows are the reference arm.
39
"""
40
from __future__ import annotations
41
42
import json
43
from pathlib import Path
44
import random
45
import re
46
import string
47
48
try:  # Python >= 3.11
49
    import re._parser as _sre_parse  # type: ignore[import-not-found]
50
except ImportError:  # pragma: no cover - Python < 3.11
51
    import sre_parse as _sre_parse  # type: ignore[no-redef]
52
53
ROOT = Path(__file__).resolve().parents[2]
54
EXPECTED_FAILURES = ROOT / "config" / "conformance_expected_failures.json"
55
SCHEMA_VERSION = 2
56
# fb-N once filed; pending:<slug> only while the coordinator files it (listed
57
# in every summary so it cannot be forgotten).
58
TICKET = re.compile(r"^(fb-\d+|pending:[a-z0-9-]+)$")
59
OUTCOMES = ("bug", "refused", "intermittent")
60
CAPABILITY_SKIP_PREFIXES = ("filesystem capability unavailable", "gated:", "gated behaviour")
61
# Capabilities that legitimately differ between ordinary hosts (macOS APFS vs
62
# Linux ext4). On an unrecorded profile only behaviours needing one of these,
63
# or gated behaviours, may skip; any other skip means a broken probe or host
64
# and the result is UNMEASURED.
65
KNOWN_FS_DEPENDENT = frozenset({"case_insensitive_fs", "case_sensitive_fs", "normalization_sensitive_fs",
66
                                "non_utf8_names"})
67
68
69
# A legitimate expected-failure pattern pins one concrete error; it must match
70
# none of these. They cover generic failure words (so "panicked"/"corrupt"
71
# cannot hide under an xfail), the kind of text a catch-all pattern swallows,
72
# and deterministic random noise.
73
_rng = random.Random(0x0A4B)
74
SENTINELS = ("", " ", "\n", "x", "panicked", "corrupt", "error", "failed", "zzz-sentinel",
75
             "thread 'main' panicked at src/main.rs:1:1", "database disk image is malformed",
76
             "internal error", "HTTP 500 Internal Server Error", "Segmentation fault: 11",
77
             "zzz-sentinel: no such behaviour exited 99",
78
             *("".join(_rng.choice(string.ascii_letters + string.digits + string.punctuation + " ")
79
                       for _ in range(_rng.randint(1, 60))) for _ in range(24)))
80
del _rng
81
82
83
def _has_literal(parsed) -> bool:
84
    """True if the parsed regex contains a literal character outside a character class."""
85
    for op, arg in parsed:
86
        name = str(op)
87
        if name == "LITERAL":
88
            return True
89
        if name in ("NOT_LITERAL", "IN", "ANY", "AT", "CATEGORY", "GROUPREF", "GROUPREF_EXISTS"):
90
            continue
91
        if name == "SUBPATTERN":
92
            if _has_literal(arg[-1]):
93
                return True
94
        elif name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"):
95
            low, _high, sub = arg
96
            if low > 0 and _has_literal(sub):
97
                return True
98
        elif name == "BRANCH":
99
            # every alternative must carry a literal for the branch to count
100
            if all(_has_literal(alt) for alt in arg[1]):
101
                return True
102
        elif name == "ATOMIC_GROUP":
103
            if _has_literal(arg):
104
                return True
105
    return False
106
107
108
def pattern_too_broad(pattern: str) -> str | None:
109
    """Why an expected-failure error pattern is a catch-all, or None if it is specific."""
110
    compiled = re.compile(pattern, re.S)
111
    for sentinel in SENTINELS:
112
        if compiled.fullmatch(sentinel):
113
            return f"matches the sentinel {sentinel!r}"
114
    if not _has_literal(_sre_parse.parse(pattern, re.S)):
115
        return "has no required literal text (wildcards/classes only)"
116
    return None
117
118
119
def fs_dependent_skip_allowed(behaviour: str) -> bool:
120
    from oakbench.conformance_matrix import BY_ID
121
    spec = BY_ID.get(behaviour)
122
    return spec is not None and (spec.gated or bool(spec.requires & KNOWN_FS_DEPENDENT))
123
124
125
def load_expected(path: Path = EXPECTED_FAILURES) -> dict:
126
    data = json.loads(Path(path).read_text())
127
    if data.get("schema_version") != SCHEMA_VERSION or not isinstance(data.get("entries"), list):
128
        raise ValueError("conformance expected-failures: unsupported schema")
129
    entries = {}
130
    for entry in data["entries"]:
131
        behaviour = entry.get("behaviour")
132
        if not behaviour or behaviour in entries:
133
            raise ValueError(f"conformance expected-failures: bad or duplicate behaviour {behaviour!r}")
134
        if not TICKET.match(str(entry.get("ticket", ""))):
135
            raise ValueError(f"conformance expected-failures: {behaviour} needs a ticket (fb-N or pending:slug)")
136
        if entry.get("outcome", "bug") not in OUTCOMES:
137
            raise ValueError(f"conformance expected-failures: {behaviour} outcome must be one of {OUTCOMES}")
138
        legs = entry.get("failed_legs")
139
        if not isinstance(legs, dict) or not legs:
140
            raise ValueError(f"conformance expected-failures: {behaviour} needs failed_legs {{leg: pattern}}")
141
        compiled = {}
142
        for leg, pattern in legs.items():
143
            if not isinstance(pattern, str) or not pattern:
144
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} needs an error pattern")
145
            try:
146
                broad = pattern_too_broad(pattern)
147
            except re.error as exc:
148
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} pattern invalid: {exc}")
149
            if broad:
150
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} pattern {pattern!r} is a "
151
                                 f"catch-all ({broad}); pin the concrete error")
152
            compiled[leg] = re.compile(pattern, re.S)
153
        profiles = entry.get("fs_profiles")
154
        if profiles is not None and (not isinstance(profiles, list) or not profiles):
155
            raise ValueError(f"conformance expected-failures: {behaviour} fs_profiles must be a non-empty list")
156
        entries[behaviour] = {**entry, "outcome": entry.get("outcome", "bug"), "patterns": compiled}
157
    coverage = data.get("coverage") or {}
158
    if not isinstance(coverage, dict):
159
        raise ValueError("conformance expected-failures: coverage must map profile -> {expected_skips}")
160
    return {"subject": data.get("subject", "oak"), "entries": entries, "coverage": coverage}
161
162
163
def pending_tickets(expected: dict) -> list:
164
    """[(behaviour, ticket, outcome)] for entries whose ticket is still pending:<slug>."""
165
    return sorted((behaviour, entry["ticket"], entry["outcome"]) for behaviour, entry in expected["entries"].items()
166
                  if str(entry["ticket"]).startswith("pending:"))
167
168
169
def row_failed_legs(row: dict) -> dict:
170
    """{failed leg: detail} for a failing row (legacy rows: {'behaviour': failure})."""
171
    legs = row.get("legs")
172
    if isinstance(legs, dict):
173
        return {name: str(leg.get("detail") or "") for name, leg in legs.items()
174
                if leg.get("status") in ("fail", "error")}
175
    return {"behaviour": str(row.get("failure") or "")} if row.get("returncode") == 1 else {}
176
177
178
def entry_applies(entry: dict, profile: str | None) -> bool:
179
    profiles = entry.get("fs_profiles")
180
    return profiles is None or profile in profiles
181
182
183
def mismatch(entry: dict, failed: dict) -> str | None:
184
    """Why a failing row does not match its expected failure, or None."""
185
    expected = set(entry["patterns"])
186
    if set(failed) != expected:
187
        return f"expected legs {sorted(expected)}, observed {sorted(failed)}"
188
    for leg, pattern in entry["patterns"].items():
189
        if not pattern.fullmatch(failed[leg]):
190
            return f"leg {leg} error changed: {failed[leg][:200]!r} does not match {pattern.pattern!r}"
191
    return None
192
193
194
def evaluate(rows: list, expected: dict, required=None) -> dict:
195
    subject = expected["subject"]
196
    entries = expected["entries"]
197
    result = {"subject": subject, "passed": [], "expected_failures": [], "refused": [], "fixed": [],
198
              "intermittent": [], "regressions": [], "unmeasured": [], "skipped": [], "git_reference_failures": [],
199
              "differential_differs": [], "profiles": [], "notes": []}
200
    seen = set()
201
    subject_rows = [row for row in rows if row.get("evidence_class") == "diagnostic_conformance"]
202
    profiles = sorted({str(row.get("fs_profile")) for row in subject_rows
203
                       if row.get("subject") == subject and row.get("fs_profile")})
204
    result["profiles"] = profiles
205
    if len(profiles) > 1:
206
        result["unmeasured"].append(f"rows mix filesystem profiles {profiles}; gate one host at a time")
207
        result["verdict"] = "UNMEASURED"
208
        return result
209
    profile = profiles[0] if len(profiles) == 1 else None
210
    missing_profile = sorted(str(row.get("scenario")) for row in subject_rows
211
                             if row.get("subject") == subject and not row.get("fs_profile"))
212
    if missing_profile:
213
        result["unmeasured"].append(f"rows without fs_profile cannot be judged: {missing_profile[:5]}")
214
    recorded = expected["coverage"].get(profile) if profile else None
215
    result["profile_recorded"] = recorded is not None
216
    if profile and recorded is None:
217
        result["notes"].append(f"filesystem profile {profile} has no recorded coverage; only known "
218
                               f"filesystem-dependent ({sorted(KNOWN_FS_DEPENDENT)}) and gated skips accepted")
219
    expected_skips = set((recorded or {}).get("expected_skips", []))
220
    for row in subject_rows:
221
        behaviour = row.get("scenario")
222
        code = row.get("returncode")
223
        if row.get("subject") != subject:
224
            if row.get("subject") == "git" and code == 1:
225
                result["git_reference_failures"].append(f"{behaviour}: {row.get('failure')}")
226
            continue
227
        seen.add(behaviour)
228
        entry = entries.get(behaviour)
229
        if entry is not None and not entry_applies(entry, profile):
230
            entry = None
231
        differential = str(row.get("git_differential") or "")
232
        if differential.startswith("differs"):
233
            result["differential_differs"].append(f"{behaviour}: {differential}")
234
        if code == 0:
235
            if entry and entry["outcome"] == "intermittent":
236
                # An intermittent failure is expected to pass most runs: a pass
237
                # is a pass, never evidence of a fix.
238
                result["passed"].append(behaviour)
239
            elif entry:
240
                result["fixed"].append(f"{behaviour}: now passes; remove {entry['ticket']} from the expected-failures list")
241
            else:
242
                result["passed"].append(behaviour)
243
        elif code == 1:
244
            failed = row_failed_legs(row)
245
            if entry is None:
246
                result["regressions"].append(f"{behaviour}: {row.get('failure')}")
247
                continue
248
            why = mismatch(entry, failed)
249
            if why:
250
                result["regressions"].append(f"{behaviour}: known failure ({entry['ticket']}) changed: {why}")
251
            else:
252
                key = {"refused": "refused", "intermittent": "intermittent"}.get(entry["outcome"], "expected_failures")
253
                result[key].append(f"{behaviour}: {entry['ticket']}")
254
        elif code == 77 and str(row.get("skip_reason", "")).startswith(CAPABILITY_SKIP_PREFIXES):
255
            if recorded is not None and behaviour not in expected_skips:
256
                result["unmeasured"].append(f"{behaviour}: coverage loss on {profile}: skipped "
257
                                            f"({row.get('skip_reason')}) but not an expected skip")
258
            elif recorded is None and not fs_dependent_skip_allowed(behaviour):
259
                result["unmeasured"].append(f"{behaviour}: skipped on unrecorded profile {profile} "
260
                                            f"({row.get('skip_reason')}); only known filesystem-dependent "
261
                                            "or gated behaviours may skip there")
262
            else:
263
                result["skipped"].append(f"{behaviour}: {row.get('skip_reason')}")
264
        else:
265
            result["unmeasured"].append(f"{behaviour}: {row.get('failure') or row.get('skip_reason') or code}")
266
    for behaviour in sorted(set(required or ()) - seen):
267
        result["unmeasured"].append(f"{behaviour}: no {subject} conformance row")
268
    if not seen:
269
        result["unmeasured"].append(f"no {subject} conformance rows")
270
    measured = sum(len(result[key]) for key in ("passed", "expected_failures", "refused", "intermittent", "fixed",
271
                                                "regressions"))
272
    pending = sorted({entry["ticket"] for entry in entries.values() if entry["ticket"].startswith("pending:")})
273
    if pending:
274
        result["notes"].append(f"unfiled tickets in the expected-failures list: {pending}")
275
    result["measured"] = measured
276
    if seen and measured == 0:
277
        result["unmeasured"].append(f"zero measured {subject} behaviours (every row skipped or unmeasured)")
278
    result["verdict"] = ("REGRESSED" if result["regressions"] else
279
                         "UNMEASURED" if result["unmeasured"] else "PASS")
280
    return result
281
282
283
def summary_lines(result: dict) -> list:
284
    lines = [f"conformance ({result['subject']}, {','.join(result['profiles']) or 'no profile'}): "
285
             f"{result['verdict']} - {len(result['passed'])} pass, {len(result['expected_failures'])} expected-fail, "
286
             f"{len(result['refused'])} policy-refused, {len(result['intermittent'])} intermittent-fail, "
287
             f"{len(result['fixed'])} fixed, "
288
             f"{len(result['regressions'])} regression(s), {len(result['unmeasured'])} unmeasured, "
289
             f"{len(result['skipped'])} capability/gated skip(s)"]
290
    for key, label in (("regressions", "REGRESSION"), ("fixed", "FIXED"), ("unmeasured", "UNMEASURED"),
291
                       ("expected_failures", "XFAIL"), ("refused", "REFUSED"), ("intermittent", "INTERMITTENT"), ("differential_differs", "GIT-DIFF"),
292
                       ("git_reference_failures", "GIT-REF"), ("skipped", "SKIP"), ("notes", "NOTE")):
293
        lines += [f"  {label} {item}" for item in result[key]]
294
    return lines