Viewing files at commit a3ea065926c9Commit detailsView latest

Browse files
scripts/oakbench/conformance_gate.py 313 lines · 17.2 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
# Subject-shaped sentinels: real conformance leg details look like
82
# "oak <cmd> exited N: error: ..." (legacy: "command (...) failed (N): b'...'"),
83
# so a pattern like `oak .* exited \d+: .*` or `.*error: .*` would swallow a panic,
84
# a corrupt store or a server 500 in that exact shape. None may match.
85
_BAD_ERRORS = ("thread 'main' panicked at src/main.rs:1:1", "error: corrupt object 0123abcd",
86
               "error: Database error: database disk image is malformed",
87
               "error: Server error: HTTP 500 Internal Server Error: internal error",
88
               "error: Database error: unable to open database file", "fatal: bad object HEAD")
89
_COMMANDS = ("oak commit --no-verify", "oak push --repo", "oak clone conformance/r1-2", "oak pull", "oak status",
90
             "git commit -q", "git push -q", "git clone -q", "git pull -q")
91
SUBJECT_SENTINELS = tuple(
92
    [f"{cmd} exited {rc}: {err}" for cmd in _COMMANDS for rc in (1, 2, 6, 101, 128) for err in _BAD_ERRORS]
93
    + [f"command ('commit', '--no-verify') failed ({rc}): b\"{err}\\n\"" for rc in (1, 2, 101)
94
       for err in _BAD_ERRORS]
95
    + [f"command ('commit', '--no-verify') failed ({rc}): b'{err}\\n'" for rc in (1, 2, 101)
96
       for err in _BAD_ERRORS if "'" not in err]
97
    + [err for err in _BAD_ERRORS] + [f"oak: {err}" for err in _BAD_ERRORS])
98
SENTINELS = SENTINELS + SUBJECT_SENTINELS
99
del _BAD_ERRORS, _COMMANDS
100
101
102
def _has_literal(parsed) -> bool:
103
    """True if the parsed regex contains a literal character outside a character class."""
104
    for op, arg in parsed:
105
        name = str(op)
106
        if name == "LITERAL":
107
            return True
108
        if name in ("NOT_LITERAL", "IN", "ANY", "AT", "CATEGORY", "GROUPREF", "GROUPREF_EXISTS"):
109
            continue
110
        if name == "SUBPATTERN":
111
            if _has_literal(arg[-1]):
112
                return True
113
        elif name in ("MAX_REPEAT", "MIN_REPEAT", "POSSESSIVE_REPEAT"):
114
            low, _high, sub = arg
115
            if low > 0 and _has_literal(sub):
116
                return True
117
        elif name == "BRANCH":
118
            # every alternative must carry a literal for the branch to count
119
            if all(_has_literal(alt) for alt in arg[1]):
120
                return True
121
        elif name == "ATOMIC_GROUP":
122
            if _has_literal(arg):
123
                return True
124
    return False
125
126
127
def pattern_too_broad(pattern: str) -> str | None:
128
    """Why an expected-failure error pattern is a catch-all, or None if it is specific."""
129
    compiled = re.compile(pattern, re.S)
130
    for sentinel in SENTINELS:
131
        if compiled.fullmatch(sentinel):
132
            return f"matches the sentinel {sentinel!r}"
133
    if not _has_literal(_sre_parse.parse(pattern, re.S)):
134
        return "has no required literal text (wildcards/classes only)"
135
    return None
136
137
138
def fs_dependent_skip_allowed(behaviour: str) -> bool:
139
    from oakbench.conformance_matrix import BY_ID
140
    spec = BY_ID.get(behaviour)
141
    return spec is not None and (spec.gated or bool(spec.requires & KNOWN_FS_DEPENDENT))
142
143
144
def load_expected(path: Path = EXPECTED_FAILURES) -> dict:
145
    data = json.loads(Path(path).read_text())
146
    if data.get("schema_version") != SCHEMA_VERSION or not isinstance(data.get("entries"), list):
147
        raise ValueError("conformance expected-failures: unsupported schema")
148
    entries = {}
149
    for entry in data["entries"]:
150
        behaviour = entry.get("behaviour")
151
        if not behaviour or behaviour in entries:
152
            raise ValueError(f"conformance expected-failures: bad or duplicate behaviour {behaviour!r}")
153
        if not TICKET.match(str(entry.get("ticket", ""))):
154
            raise ValueError(f"conformance expected-failures: {behaviour} needs a ticket (fb-N or pending:slug)")
155
        if entry.get("outcome", "bug") not in OUTCOMES:
156
            raise ValueError(f"conformance expected-failures: {behaviour} outcome must be one of {OUTCOMES}")
157
        legs = entry.get("failed_legs")
158
        if not isinstance(legs, dict) or not legs:
159
            raise ValueError(f"conformance expected-failures: {behaviour} needs failed_legs {{leg: pattern}}")
160
        compiled = {}
161
        for leg, pattern in legs.items():
162
            if not isinstance(pattern, str) or not pattern:
163
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} needs an error pattern")
164
            try:
165
                broad = pattern_too_broad(pattern)
166
            except re.error as exc:
167
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} pattern invalid: {exc}")
168
            if broad:
169
                raise ValueError(f"conformance expected-failures: {behaviour}/{leg} pattern {pattern!r} is a "
170
                                 f"catch-all ({broad}); pin the concrete error")
171
            compiled[leg] = re.compile(pattern, re.S)
172
        profiles = entry.get("fs_profiles")
173
        if profiles is not None and (not isinstance(profiles, list) or not profiles):
174
            raise ValueError(f"conformance expected-failures: {behaviour} fs_profiles must be a non-empty list")
175
        entries[behaviour] = {**entry, "outcome": entry.get("outcome", "bug"), "patterns": compiled}
176
    coverage = data.get("coverage") or {}
177
    if not isinstance(coverage, dict):
178
        raise ValueError("conformance expected-failures: coverage must map profile -> {expected_skips}")
179
    return {"subject": data.get("subject", "oak"), "entries": entries, "coverage": coverage}
180
181
182
def pending_tickets(expected: dict) -> list:
183
    """[(behaviour, ticket, outcome)] for entries whose ticket is still pending:<slug>."""
184
    return sorted((behaviour, entry["ticket"], entry["outcome"]) for behaviour, entry in expected["entries"].items()
185
                  if str(entry["ticket"]).startswith("pending:"))
186
187
188
def row_failed_legs(row: dict) -> dict:
189
    """{failed leg: detail} for a failing row (legacy rows: {'behaviour': failure})."""
190
    legs = row.get("legs")
191
    if isinstance(legs, dict):
192
        return {name: str(leg.get("detail") or "") for name, leg in legs.items()
193
                if leg.get("status") in ("fail", "error")}
194
    return {"behaviour": str(row.get("failure") or "")} if row.get("returncode") == 1 else {}
195
196
197
def entry_applies(entry: dict, profile: str | None) -> bool:
198
    profiles = entry.get("fs_profiles")
199
    return profiles is None or profile in profiles
200
201
202
def mismatch(entry: dict, failed: dict) -> str | None:
203
    """Why a failing row does not match its expected failure, or None."""
204
    expected = set(entry["patterns"])
205
    if set(failed) != expected:
206
        return f"expected legs {sorted(expected)}, observed {sorted(failed)}"
207
    for leg, pattern in entry["patterns"].items():
208
        if not pattern.fullmatch(failed[leg]):
209
            return f"leg {leg} error changed: {failed[leg][:200]!r} does not match {pattern.pattern!r}"
210
    return None
211
212
213
def evaluate(rows: list, expected: dict, required=None) -> dict:
214
    subject = expected["subject"]
215
    entries = expected["entries"]
216
    result = {"subject": subject, "passed": [], "expected_failures": [], "refused": [], "fixed": [],
217
              "intermittent": [], "regressions": [], "unmeasured": [], "skipped": [], "git_reference_failures": [],
218
              "differential_differs": [], "profiles": [], "notes": []}
219
    seen = set()
220
    subject_rows = [row for row in rows if row.get("evidence_class") == "diagnostic_conformance"]
221
    profiles = sorted({str(row.get("fs_profile")) for row in subject_rows
222
                       if row.get("subject") == subject and row.get("fs_profile")})
223
    result["profiles"] = profiles
224
    if len(profiles) > 1:
225
        result["unmeasured"].append(f"rows mix filesystem profiles {profiles}; gate one host at a time")
226
        result["verdict"] = "UNMEASURED"
227
        return result
228
    profile = profiles[0] if len(profiles) == 1 else None
229
    missing_profile = sorted(str(row.get("scenario")) for row in subject_rows
230
                             if row.get("subject") == subject and not row.get("fs_profile"))
231
    if missing_profile:
232
        result["unmeasured"].append(f"rows without fs_profile cannot be judged: {missing_profile[:5]}")
233
    recorded = expected["coverage"].get(profile) if profile else None
234
    result["profile_recorded"] = recorded is not None
235
    if profile and recorded is None:
236
        result["notes"].append(f"filesystem profile {profile} has no recorded coverage; only known "
237
                               f"filesystem-dependent ({sorted(KNOWN_FS_DEPENDENT)}) and gated skips accepted")
238
    expected_skips = set((recorded or {}).get("expected_skips", []))
239
    for row in subject_rows:
240
        behaviour = row.get("scenario")
241
        code = row.get("returncode")
242
        if row.get("subject") != subject:
243
            if row.get("subject") == "git" and code == 1:
244
                result["git_reference_failures"].append(f"{behaviour}: {row.get('failure')}")
245
            continue
246
        seen.add(behaviour)
247
        entry = entries.get(behaviour)
248
        if entry is not None and not entry_applies(entry, profile):
249
            entry = None
250
        differential = str(row.get("git_differential") or "")
251
        if differential.startswith("differs"):
252
            result["differential_differs"].append(f"{behaviour}: {differential}")
253
        if code == 0:
254
            if entry and entry["outcome"] == "intermittent":
255
                # An intermittent failure is expected to pass most runs: a pass
256
                # is a pass, never evidence of a fix.
257
                result["passed"].append(behaviour)
258
            elif entry:
259
                result["fixed"].append(f"{behaviour}: now passes; remove {entry['ticket']} from the expected-failures list")
260
            else:
261
                result["passed"].append(behaviour)
262
        elif code == 1:
263
            failed = row_failed_legs(row)
264
            if entry is None:
265
                result["regressions"].append(f"{behaviour}: {row.get('failure')}")
266
                continue
267
            why = mismatch(entry, failed)
268
            if why:
269
                result["regressions"].append(f"{behaviour}: known failure ({entry['ticket']}) changed: {why}")
270
            else:
271
                key = {"refused": "refused", "intermittent": "intermittent"}.get(entry["outcome"], "expected_failures")
272
                result[key].append(f"{behaviour}: {entry['ticket']}")
273
        elif code == 77 and str(row.get("skip_reason", "")).startswith(CAPABILITY_SKIP_PREFIXES):
274
            if recorded is not None and behaviour not in expected_skips:
275
                result["unmeasured"].append(f"{behaviour}: coverage loss on {profile}: skipped "
276
                                            f"({row.get('skip_reason')}) but not an expected skip")
277
            elif recorded is None and not fs_dependent_skip_allowed(behaviour):
278
                result["unmeasured"].append(f"{behaviour}: skipped on unrecorded profile {profile} "
279
                                            f"({row.get('skip_reason')}); only known filesystem-dependent "
280
                                            "or gated behaviours may skip there")
281
            else:
282
                result["skipped"].append(f"{behaviour}: {row.get('skip_reason')}")
283
        else:
284
            result["unmeasured"].append(f"{behaviour}: {row.get('failure') or row.get('skip_reason') or code}")
285
    for behaviour in sorted(set(required or ()) - seen):
286
        result["unmeasured"].append(f"{behaviour}: no {subject} conformance row")
287
    if not seen:
288
        result["unmeasured"].append(f"no {subject} conformance rows")
289
    measured = sum(len(result[key]) for key in ("passed", "expected_failures", "refused", "intermittent", "fixed",
290
                                                "regressions"))
291
    pending = sorted({entry["ticket"] for entry in entries.values() if entry["ticket"].startswith("pending:")})
292
    if pending:
293
        result["notes"].append(f"unfiled tickets in the expected-failures list: {pending}")
294
    result["measured"] = measured
295
    if seen and measured == 0:
296
        result["unmeasured"].append(f"zero measured {subject} behaviours (every row skipped or unmeasured)")
297
    result["verdict"] = ("REGRESSED" if result["regressions"] else
298
                         "UNMEASURED" if result["unmeasured"] else "PASS")
299
    return result
300
301
302
def summary_lines(result: dict) -> list:
303
    lines = [f"conformance ({result['subject']}, {','.join(result['profiles']) or 'no profile'}): "
304
             f"{result['verdict']} - {len(result['passed'])} pass, {len(result['expected_failures'])} expected-fail, "
305
             f"{len(result['refused'])} policy-refused, {len(result['intermittent'])} intermittent-fail, "
306
             f"{len(result['fixed'])} fixed, "
307
             f"{len(result['regressions'])} regression(s), {len(result['unmeasured'])} unmeasured, "
308
             f"{len(result['skipped'])} capability/gated skip(s)"]
309
    for key, label in (("regressions", "REGRESSION"), ("fixed", "FIXED"), ("unmeasured", "UNMEASURED"),
310
                       ("expected_failures", "XFAIL"), ("refused", "REFUSED"), ("intermittent", "INTERMITTENT"), ("differential_differs", "GIT-DIFF"),
311
                       ("git_reference_failures", "GIT-REF"), ("skipped", "SKIP"), ("notes", "NOTE")):
312
        lines += [f"  {label} {item}" for item in result[key]]
313
    return lines