| 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 |
|
| 31 | Only the gated subject (Oak) is judged; Git rows are the reference arm.
|
| 32 | """
|
| 33 | from __future__ import annotations
|
| 34 |
|
| 35 | import json
|
| 36 | from pathlib import Path
|
| 37 | import re
|
| 38 |
|
| 39 | ROOT = Path(__file__).resolve().parents[2]
|
| 40 | EXPECTED_FAILURES = ROOT / "config" / "conformance_expected_failures.json"
|
| 41 | SCHEMA_VERSION = 2
|
| 42 | # fb-N once filed; pending:<slug> only while the coordinator files it (listed
|
| 43 | # in every summary so it cannot be forgotten).
|
| 44 | TICKET = re.compile(r"^(fb-\d+|pending:[a-z0-9-]+)$")
|
| 45 | OUTCOMES = ("bug", "refused", "intermittent")
|
| 46 | CAPABILITY_SKIP_PREFIXES = ("filesystem capability unavailable", "gated:", "gated behaviour")
|
| 47 | # Capabilities that legitimately differ between ordinary hosts (macOS APFS vs
|
| 48 | # Linux ext4). On an unrecorded profile only behaviours needing one of these,
|
| 49 | # or gated behaviours, may skip; any other skip means a broken probe or host
|
| 50 | # and the result is UNMEASURED.
|
| 51 | KNOWN_FS_DEPENDENT = frozenset({"case_insensitive_fs", "case_sensitive_fs", "normalization_sensitive_fs",
|
| 52 | "non_utf8_names"})
|
| 53 |
|
| 54 |
|
| 55 | def fs_dependent_skip_allowed(behaviour: str) -> bool:
|
| 56 | from oakbench.conformance_matrix import BY_ID
|
| 57 | spec = BY_ID.get(behaviour)
|
| 58 | return spec is not None and (spec.gated or bool(spec.requires & KNOWN_FS_DEPENDENT))
|
| 59 |
|
| 60 |
|
| 61 | def load_expected(path: Path = EXPECTED_FAILURES) -> dict:
|
| 62 | data = json.loads(Path(path).read_text())
|
| 63 | if data.get("schema_version") != SCHEMA_VERSION or not isinstance(data.get("entries"), list):
|
| 64 | raise ValueError("conformance expected-failures: unsupported schema")
|
| 65 | entries = {}
|
| 66 | for entry in data["entries"]:
|
| 67 | behaviour = entry.get("behaviour")
|
| 68 | if not behaviour or behaviour in entries:
|
| 69 | raise ValueError(f"conformance expected-failures: bad or duplicate behaviour {behaviour!r}")
|
| 70 | if not TICKET.match(str(entry.get("ticket", ""))):
|
| 71 | raise ValueError(f"conformance expected-failures: {behaviour} needs a ticket (fb-N or pending:slug)")
|
| 72 | if entry.get("outcome", "bug") not in OUTCOMES:
|
| 73 | raise ValueError(f"conformance expected-failures: {behaviour} outcome must be one of {OUTCOMES}")
|
| 74 | legs = entry.get("failed_legs")
|
| 75 | if not isinstance(legs, dict) or not legs:
|
| 76 | raise ValueError(f"conformance expected-failures: {behaviour} needs failed_legs {{leg: pattern}}")
|
| 77 | compiled = {}
|
| 78 | for leg, pattern in legs.items():
|
| 79 | if not isinstance(pattern, str) or not pattern:
|
| 80 | raise ValueError(f"conformance expected-failures: {behaviour}/{leg} needs an error pattern")
|
| 81 | compiled[leg] = re.compile(pattern, re.S)
|
| 82 | profiles = entry.get("fs_profiles")
|
| 83 | if profiles is not None and (not isinstance(profiles, list) or not profiles):
|
| 84 | raise ValueError(f"conformance expected-failures: {behaviour} fs_profiles must be a non-empty list")
|
| 85 | entries[behaviour] = {**entry, "outcome": entry.get("outcome", "bug"), "patterns": compiled}
|
| 86 | coverage = data.get("coverage") or {}
|
| 87 | if not isinstance(coverage, dict):
|
| 88 | raise ValueError("conformance expected-failures: coverage must map profile -> {expected_skips}")
|
| 89 | return {"subject": data.get("subject", "oak"), "entries": entries, "coverage": coverage}
|
| 90 |
|
| 91 |
|
| 92 | def row_failed_legs(row: dict) -> dict:
|
| 93 | """{failed leg: detail} for a failing row (legacy rows: {'behaviour': failure})."""
|
| 94 | legs = row.get("legs")
|
| 95 | if isinstance(legs, dict):
|
| 96 | return {name: str(leg.get("detail") or "") for name, leg in legs.items()
|
| 97 | if leg.get("status") in ("fail", "error")}
|
| 98 | return {"behaviour": str(row.get("failure") or "")} if row.get("returncode") == 1 else {}
|
| 99 |
|
| 100 |
|
| 101 | def entry_applies(entry: dict, profile: str | None) -> bool:
|
| 102 | profiles = entry.get("fs_profiles")
|
| 103 | return profiles is None or profile in profiles
|
| 104 |
|
| 105 |
|
| 106 | def mismatch(entry: dict, failed: dict) -> str | None:
|
| 107 | """Why a failing row does not match its expected failure, or None."""
|
| 108 | expected = set(entry["patterns"])
|
| 109 | if set(failed) != expected:
|
| 110 | return f"expected legs {sorted(expected)}, observed {sorted(failed)}"
|
| 111 | for leg, pattern in entry["patterns"].items():
|
| 112 | if not pattern.fullmatch(failed[leg]):
|
| 113 | return f"leg {leg} error changed: {failed[leg][:200]!r} does not match {pattern.pattern!r}"
|
| 114 | return None
|
| 115 |
|
| 116 |
|
| 117 | def evaluate(rows: list, expected: dict, required=None) -> dict:
|
| 118 | subject = expected["subject"]
|
| 119 | entries = expected["entries"]
|
| 120 | result = {"subject": subject, "passed": [], "expected_failures": [], "refused": [], "fixed": [],
|
| 121 | "intermittent": [], "regressions": [], "unmeasured": [], "skipped": [], "git_reference_failures": [],
|
| 122 | "differential_differs": [], "profiles": [], "notes": []}
|
| 123 | seen = set()
|
| 124 | subject_rows = [row for row in rows if row.get("evidence_class") == "diagnostic_conformance"]
|
| 125 | profiles = sorted({str(row.get("fs_profile")) for row in subject_rows
|
| 126 | if row.get("subject") == subject and row.get("fs_profile")})
|
| 127 | result["profiles"] = profiles
|
| 128 | if len(profiles) > 1:
|
| 129 | result["unmeasured"].append(f"rows mix filesystem profiles {profiles}; gate one host at a time")
|
| 130 | result["verdict"] = "UNMEASURED"
|
| 131 | return result
|
| 132 | profile = profiles[0] if len(profiles) == 1 else None
|
| 133 | missing_profile = sorted(str(row.get("scenario")) for row in subject_rows
|
| 134 | if row.get("subject") == subject and not row.get("fs_profile"))
|
| 135 | if missing_profile:
|
| 136 | result["unmeasured"].append(f"rows without fs_profile cannot be judged: {missing_profile[:5]}")
|
| 137 | recorded = expected["coverage"].get(profile) if profile else None
|
| 138 | result["profile_recorded"] = recorded is not None
|
| 139 | if profile and recorded is None:
|
| 140 | result["notes"].append(f"filesystem profile {profile} has no recorded coverage; only known "
|
| 141 | f"filesystem-dependent ({sorted(KNOWN_FS_DEPENDENT)}) and gated skips accepted")
|
| 142 | expected_skips = set((recorded or {}).get("expected_skips", []))
|
| 143 | for row in subject_rows:
|
| 144 | behaviour = row.get("scenario")
|
| 145 | code = row.get("returncode")
|
| 146 | if row.get("subject") != subject:
|
| 147 | if row.get("subject") == "git" and code == 1:
|
| 148 | result["git_reference_failures"].append(f"{behaviour}: {row.get('failure')}")
|
| 149 | continue
|
| 150 | seen.add(behaviour)
|
| 151 | entry = entries.get(behaviour)
|
| 152 | if entry is not None and not entry_applies(entry, profile):
|
| 153 | entry = None
|
| 154 | differential = str(row.get("git_differential") or "")
|
| 155 | if differential.startswith("differs"):
|
| 156 | result["differential_differs"].append(f"{behaviour}: {differential}")
|
| 157 | if code == 0:
|
| 158 | if entry and entry["outcome"] == "intermittent":
|
| 159 | # An intermittent failure is expected to pass most runs: a pass
|
| 160 | # is a pass, never evidence of a fix.
|
| 161 | result["passed"].append(behaviour)
|
| 162 | elif entry:
|
| 163 | result["fixed"].append(f"{behaviour}: now passes; remove {entry['ticket']} from the expected-failures list")
|
| 164 | else:
|
| 165 | result["passed"].append(behaviour)
|
| 166 | elif code == 1:
|
| 167 | failed = row_failed_legs(row)
|
| 168 | if entry is None:
|
| 169 | result["regressions"].append(f"{behaviour}: {row.get('failure')}")
|
| 170 | continue
|
| 171 | why = mismatch(entry, failed)
|
| 172 | if why:
|
| 173 | result["regressions"].append(f"{behaviour}: known failure ({entry['ticket']}) changed: {why}")
|
| 174 | else:
|
| 175 | key = {"refused": "refused", "intermittent": "intermittent"}.get(entry["outcome"], "expected_failures")
|
| 176 | result[key].append(f"{behaviour}: {entry['ticket']}")
|
| 177 | elif code == 77 and str(row.get("skip_reason", "")).startswith(CAPABILITY_SKIP_PREFIXES):
|
| 178 | if recorded is not None and behaviour not in expected_skips:
|
| 179 | result["unmeasured"].append(f"{behaviour}: coverage loss on {profile}: skipped "
|
| 180 | f"({row.get('skip_reason')}) but not an expected skip")
|
| 181 | elif recorded is None and not fs_dependent_skip_allowed(behaviour):
|
| 182 | result["unmeasured"].append(f"{behaviour}: skipped on unrecorded profile {profile} "
|
| 183 | f"({row.get('skip_reason')}); only known filesystem-dependent "
|
| 184 | "or gated behaviours may skip there")
|
| 185 | else:
|
| 186 | result["skipped"].append(f"{behaviour}: {row.get('skip_reason')}")
|
| 187 | else:
|
| 188 | result["unmeasured"].append(f"{behaviour}: {row.get('failure') or row.get('skip_reason') or code}")
|
| 189 | for behaviour in sorted(set(required or ()) - seen):
|
| 190 | result["unmeasured"].append(f"{behaviour}: no {subject} conformance row")
|
| 191 | if not seen:
|
| 192 | result["unmeasured"].append(f"no {subject} conformance rows")
|
| 193 | measured = sum(len(result[key]) for key in ("passed", "expected_failures", "refused", "intermittent", "fixed",
|
| 194 | "regressions"))
|
| 195 | pending = sorted({entry["ticket"] for entry in entries.values() if entry["ticket"].startswith("pending:")})
|
| 196 | if pending:
|
| 197 | result["notes"].append(f"unfiled tickets in the expected-failures list: {pending}")
|
| 198 | result["measured"] = measured
|
| 199 | if seen and measured == 0:
|
| 200 | result["unmeasured"].append(f"zero measured {subject} behaviours (every row skipped or unmeasured)")
|
| 201 | result["verdict"] = ("REGRESSED" if result["regressions"] else
|
| 202 | "UNMEASURED" if result["unmeasured"] else "PASS")
|
| 203 | return result
|
| 204 |
|
| 205 |
|
| 206 | def summary_lines(result: dict) -> list:
|
| 207 | lines = [f"conformance ({result['subject']}, {','.join(result['profiles']) or 'no profile'}): "
|
| 208 | f"{result['verdict']} - {len(result['passed'])} pass, {len(result['expected_failures'])} expected-fail, "
|
| 209 | f"{len(result['refused'])} policy-refused, {len(result['intermittent'])} intermittent-fail, "
|
| 210 | f"{len(result['fixed'])} fixed, "
|
| 211 | f"{len(result['regressions'])} regression(s), {len(result['unmeasured'])} unmeasured, "
|
| 212 | f"{len(result['skipped'])} capability/gated skip(s)"]
|
| 213 | for key, label in (("regressions", "REGRESSION"), ("fixed", "FIXED"), ("unmeasured", "UNMEASURED"),
|
| 214 | ("expected_failures", "XFAIL"), ("refused", "REFUSED"), ("intermittent", "INTERMITTENT"), ("differential_differs", "GIT-DIFF"),
|
| 215 | ("git_reference_failures", "GIT-REF"), ("skipped", "SKIP"), ("notes", "NOTE")):
|
| 216 | lines += [f" {label} {item}" for item in result[key]]
|
| 217 | return lines
|