| 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
|