From eda45a6857ff9ce8e47348e7950920953a74152a Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 11 May 2026 10:48:14 -0400 Subject: [PATCH] ci: fix-marker regression guard (witness-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fast per-PR gate that asserts previously-shipped fixes are still present in the tree — the CI analogue of the ruflo witness fix-marker system, but self-contained (no plugin dependency, reviewable as plain JSON). Complements the heavier checks (firmware build, deterministic pipeline proof, release witness bundle) by catching the silent-revert class of regression that build+test wouldn't. - scripts/fix-markers.json manifest: 11 markers (RuView#396, #521, #517, #505, #354, #263, #266/#321, #265, #232/#375/#385/#386/#390, ADR-028 proof + witness bundle). Each has files / require (literal substring or /regex/) / optional forbid / rationale / ref. - scripts/check_fix_markers.py stdlib-only checker. Exit 0 clean / 1 regression / 2 bad manifest. Modes: --list, --json, --only ID. - .github/workflows/fix-regression-guard.yml runs on PR + push to main/master; gates on the checker and writes the result table into the run summary + an artifact. If a fix is intentionally removed, update scripts/fix-markers.json in the same PR with a rationale — the diff becomes the audit trail. Co-Authored-By: claude-flow --- .github/workflows/fix-regression-guard.yml | 54 ++++++ scripts/check_fix_markers.py | 190 +++++++++++++++++++++ scripts/fix-markers.json | 115 +++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 .github/workflows/fix-regression-guard.yml create mode 100644 scripts/check_fix_markers.py create mode 100644 scripts/fix-markers.json diff --git a/.github/workflows/fix-regression-guard.yml b/.github/workflows/fix-regression-guard.yml new file mode 100644 index 00000000..440ddab3 --- /dev/null +++ b/.github/workflows/fix-regression-guard.yml @@ -0,0 +1,54 @@ +name: Fix-Marker Regression Guard + +# Asserts that previously-shipped fixes are still present in the tree. +# Manifest: scripts/fix-markers.json Checker: scripts/check_fix_markers.py +# Run locally: python scripts/check_fix_markers.py (also --list / --json) +# +# This complements the heavyweight checks (firmware build, deterministic +# pipeline proof, witness bundle) with a fast per-PR "did someone revert a +# known fix?" gate — the CI analogue of the ruflo witness fix-marker system. + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + fix-markers: + name: Verify fix markers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate the manifest is well-formed JSON + run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')" + + - name: Check fix markers + run: python scripts/check_fix_markers.py + + - name: Emit machine-readable result (for the run summary) + if: always() + run: | + python scripts/check_fix_markers.py --json > fix-markers-result.json || true + { + echo '### Fix-marker regression guard' + echo '' + echo '```' + python scripts/check_fix_markers.py || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload result artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fix-markers-result + path: fix-markers-result.json + retention-days: 30 diff --git a/scripts/check_fix_markers.py b/scripts/check_fix_markers.py new file mode 100644 index 00000000..ab21bad9 --- /dev/null +++ b/scripts/check_fix_markers.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Fix-marker regression guard for RuView. + +Reads ``scripts/fix-markers.json`` and asserts that every previously-shipped +fix is still present in the codebase: + +* every file listed in a marker must exist; +* every ``require`` pattern must appear in at least one of the marker's files + (a missing pattern means the fix was probably reverted); +* no ``forbid`` pattern may appear in any of the marker's files + (a re-appearing anti-pattern means the bug was re-introduced). + +A pattern is a literal substring by default. Wrap it in ``/.../`` to treat it +as a (multiline, case-sensitive) regular expression, e.g. ``"/fall_thresh\\s*=\\s*2\\.0/"``. + +This is a stdlib-only script — no dependencies, runs anywhere Python 3.8+ does. + +Usage:: + + python scripts/check_fix_markers.py # check everything (CI) + python scripts/check_fix_markers.py --list # list all markers + python scripts/check_fix_markers.py --json # machine-readable result + python scripts/check_fix_markers.py --only RuView#396 RuView#521 + +Exit codes: 0 = all markers OK, 1 = one or more regressions, 2 = bad manifest. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +MANIFEST_PATH = REPO_ROOT / "scripts" / "fix-markers.json" + +# Best-effort UTF-8 stdout (Windows consoles default to cp1252); harmless on +# Linux/CI where it's already UTF-8. We still keep all symbols ASCII below so +# the script works even if reconfigure() is unavailable. +try: # pragma: no cover - environment-dependent + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +# ANSI colours — disabled automatically when stdout isn't a TTY (CI logs are +# plain either way, but keep them readable locally). +_TTY = sys.stdout.isatty() +def _c(code: str, s: str) -> str: + return f"\033[{code}m{s}\033[0m" if _TTY else s +GREEN = lambda s: _c("32", s) +RED = lambda s: _c("31", s) +YELLOW = lambda s: _c("33", s) +DIM = lambda s: _c("2", s) +BOLD = lambda s: _c("1", s) + +OK_MARK = "PASS" +BAD_MARK = "FAIL" +ARROW = "->" + + +class ManifestError(Exception): + pass + + +def load_manifest() -> dict: + if not MANIFEST_PATH.exists(): + raise ManifestError(f"manifest not found: {MANIFEST_PATH}") + try: + data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ManifestError(f"manifest is not valid JSON: {e}") from e + if not isinstance(data, dict) or not isinstance(data.get("markers"), list): + raise ManifestError("manifest must be an object with a 'markers' array") + ids = [m.get("id") for m in data["markers"]] + dupes = {i for i in ids if ids.count(i) > 1} + if dupes: + raise ManifestError(f"duplicate marker ids: {sorted(dupes)}") + return data + + +def _pattern_found(text: str, pattern: str) -> bool: + if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"): + return re.search(pattern[1:-1], text, re.MULTILINE) is not None + return pattern in text + + +def check_marker(marker: dict) -> tuple[bool, list[str]]: + """Return (ok, problems) for a single marker.""" + problems: list[str] = [] + files = marker.get("files", []) + require = marker.get("require", []) + forbid = marker.get("forbid", []) + + if not files: + problems.append("marker lists no files") + return False, problems + + contents: dict[str, str] = {} + for rel in files: + p = REPO_ROOT / rel + if not p.exists(): + problems.append(f"missing file: {rel}") + continue + try: + contents[rel] = p.read_text(encoding="utf-8", errors="replace") + except OSError as e: + problems.append(f"cannot read {rel}: {e}") + + haystack = "\n".join(contents.values()) + for pat in require: + if not _pattern_found(haystack, pat): + problems.append(f"required marker absent (fix likely reverted): {pat!r}") + for pat in forbid: + for rel, text in contents.items(): + if _pattern_found(text, pat): + problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}") + + return (len(problems) == 0), problems + + +def cmd_list(manifest: dict) -> int: + print(BOLD(f"{len(manifest['markers'])} fix markers tracked:\n")) + for m in manifest["markers"]: + print(f" {BOLD(m['id']):<28} {m.get('title', '')}") + if m.get("ref"): + print(DIM(f" {m['ref']}")) + for f in m.get("files", []): + print(DIM(f" - {f}")) + return 0 + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--list", action="store_true", help="list all markers and exit") + ap.add_argument("--json", action="store_true", help="emit a JSON result object") + ap.add_argument("--only", nargs="+", metavar="ID", help="only check the given marker ids") + args = ap.parse_args(argv) + + try: + manifest = load_manifest() + except ManifestError as e: + print(RED(f"[manifest error] {e}"), file=sys.stderr) + return 2 + + if args.list: + return cmd_list(manifest) + + markers = manifest["markers"] + if args.only: + wanted = set(args.only) + markers = [m for m in markers if m["id"] in wanted] + unknown = wanted - {m["id"] for m in markers} + if unknown: + print(RED(f"[error] unknown marker id(s): {sorted(unknown)}"), file=sys.stderr) + return 2 + + results = [] + failed = 0 + for m in markers: + ok, problems = check_marker(m) + results.append({"id": m["id"], "title": m.get("title", ""), "ok": ok, "problems": problems}) + if not ok: + failed += 1 + + if args.json: + print(json.dumps({"ok": failed == 0, "checked": len(markers), "failed": failed, "markers": results}, indent=2)) + return 0 if failed == 0 else 1 + + print(BOLD(f"Fix-marker regression guard - {len(markers)} marker(s)\n")) + for r in results: + if r["ok"]: + print(f" {GREEN('[' + OK_MARK + ']')} {r['id']:<28} {DIM(r['title'])}") + else: + print(f" {RED('[' + BAD_MARK + ']')} {BOLD(r['id']):<28} {r['title']}") + for p in r["problems"]: + print(f" {RED(ARROW)} {p}") + print() + if failed: + print(RED(BOLD(f"{failed}/{len(markers)} marker(s) regressed."))) + print(DIM(" A reverted fix is a regression. Restore the marker, or - if the change is")) + print(DIM(" intentional - update scripts/fix-markers.json in the same PR with a rationale.")) + return 1 + print(GREEN(BOLD(f"All {len(markers)} fix markers present."))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/fix-markers.json b/scripts/fix-markers.json new file mode 100644 index 00000000..4cb65b7e --- /dev/null +++ b/scripts/fix-markers.json @@ -0,0 +1,115 @@ +{ + "_comment": "Fix-marker regression guard for RuView. Each marker asserts that a previously-shipped fix is still present. CI (.github/workflows/fix-regression-guard.yml) fails if a `require` pattern is missing from all of a marker's `files` (the fix was likely reverted) or if a `forbid` pattern reappears (the bug was re-introduced). Run locally: `python scripts/check_fix_markers.py` (or `--list`, `--json`, `--only ID`). Patterns are literal substrings unless wrapped in /.../ (regex). Add a marker whenever you ship a fix that would be expensive to silently lose.", + "schema_version": 1, + "markers": [ + { + "id": "RuView#396", + "title": "ESP32-S3 CSI: MGMT-only promiscuous filter (SPI flash cache race crash fix)", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["WIFI_PROMIS_FILTER_MASK_MGMT", "RuView#396"], + "rationale": "Promiscuous MGMT+DATA produces 100-500 Hz HW interrupts that crash Core 0 in wDev_ProcessFiq (SPI flash cache race in the WiFi blob). Reverting to the full filter reintroduces the boot-loop / crash.", + "ref": "https://github.com/ruvnet/RuView/issues/396" + }, + { + "id": "RuView#521", + "title": "ESP32-S3 CSI: disable WiFi modem sleep (WIFI_PS_NONE) so the CSI callback isn't starved", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["esp_wifi_set_ps(WIFI_PS_NONE)", "RuView#521"], + "rationale": "The ESP-IDF STA default WIFI_PS_MIN_MODEM lets the modem sleep between DTIM beacons; combined with the MGMT-only filter the per-second CSI yield collapses toward 0 pps. csi_collector_init() must force WIFI_PS_NONE.", + "ref": "https://github.com/ruvnet/RuView/issues/521" + }, + { + "id": "RuView#517", + "title": "Aggregator classifies sibling RuView UDP packet magics instead of erroring on them", + "files": [ + "v2/crates/wifi-densepose-hardware/src/esp32_parser.rs", + "v2/crates/wifi-densepose-hardware/src/error.rs", + "v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs" + ], + "require": ["ruview_sibling_packet_name", "NonCsiPacket", "RUVIEW_VITALS_MAGIC"], + "rationale": "The firmware multiplexes 0xC5110002..0xC5110007 (vitals, feature, fused, compressed, feature-state, temporal) onto the CSI UDP port. The parser must report these as ParseError::NonCsiPacket so the aggregator can skip them, not log 'invalid magic' parse-error noise.", + "ref": "https://github.com/ruvnet/RuView/issues/517" + }, + { + "id": "RuView#505", + "title": "Firmware release: version.txt must match the release tag (firmware-ci version-guard)", + "files": [".github/workflows/firmware-ci.yml"], + "require": ["version-guard", "version.txt"], + "rationale": "v0.6.3-esp32 shipped a binary that internally identified as 0.6.2 because version.txt was never bumped. The version-guard job fails the release run when the tag's X.Y.Z doesn't match firmware/esp32-csi-node/version.txt.", + "ref": "https://github.com/ruvnet/RuView/issues/505" + }, + { + "id": "RuView#354", + "title": "Firmware embeds its version from version.txt and logs it at boot", + "files": [ + "firmware/esp32-csi-node/CMakeLists.txt", + "firmware/esp32-csi-node/main/main.c" + ], + "require": ["PROJECT_VER", "version.txt", "esp_app_get_description"], + "rationale": "esp_app_get_description()->version must derive from version.txt (CMake file(STRINGS ...)), and the boot log line surfaces it for fleet monitoring.", + "ref": "https://github.com/ruvnet/RuView/issues/354" + }, + { + "id": "RuView#263", + "title": "Fall detection: default threshold 15.0 rad/s2 + consecutive-frame debounce + cooldown", + "files": [ + "firmware/esp32-csi-node/main/nvs_config.c", + "firmware/esp32-csi-node/main/edge_processing.c", + "firmware/esp32-csi-node/main/edge_processing.h" + ], + "require": ["15.0f", "EDGE_FALL_CONSEC_MIN", "EDGE_FALL_COOLDOWN_MS"], + "forbid": ["/fall_thresh\\s*=\\s*2\\.0f\\b/"], + "rationale": "Default fall_thresh of 2.0 rad/s2 caused alert storms (false positives). 15.0 with a 3-consecutive-frame debounce + 5 s cooldown verified 0 false alerts in 600 frames on COM7.", + "ref": "https://github.com/ruvnet/RuView/issues/263" + }, + { + "id": "RuView#266-321", + "title": "Edge DSP task: batch limit so it can't starve IDLE1 and trip the task watchdog", + "files": ["firmware/esp32-csi-node/main/edge_processing.c", "firmware/esp32-csi-node/main/edge_processing.h"], + "require": ["EDGE_BATCH_LIMIT"], + "rationale": "On busy LANs the edge DSP task processed frames back-to-back with only 1-tick yields, starving IDLE1 enough to trip the 5-second task watchdog. The batch limit forces a longer yield every N frames.", + "ref": "https://github.com/ruvnet/RuView/issues/266" + }, + { + "id": "RuView#265", + "title": "4 MB flash variant: dual-OTA partition table + 4mb sdkconfig, built by firmware-ci", + "files": [ + "firmware/esp32-csi-node/partitions_4mb.csv", + "firmware/esp32-csi-node/sdkconfig.defaults.4mb", + ".github/workflows/firmware-ci.yml" + ], + "require": ["sdkconfig.defaults.4mb"], + "rationale": "Support for ESP32-S3-N16R8 / N8R2 and other 4 MB boards. The firmware-ci build matrix must keep building the 4mb variant so it doesn't bit-rot.", + "ref": "https://github.com/ruvnet/RuView/issues/265" + }, + { + "id": "RuView#232-375-385-386-390", + "title": "ESP32-S3 CSI: defensive early-capture of NVS config before wifi_init_sta() corrupts it", + "files": ["firmware/esp32-csi-node/main/csi_collector.c"], + "require": ["early capture", "s_filter_mac"], + "rationale": "wifi_init_sta() can clobber g_nvs_config (confirmed on device 80:b5:4e:c1:be:b8). Module-local statics must be captured before WiFi init and used by the CSI callback instead of g_nvs_config.", + "ref": "https://github.com/ruvnet/RuView/issues/390" + }, + { + "id": "ADR-028-proof", + "title": "Deterministic pipeline proof (Trust Kill Switch): artifacts present and re-run in CI", + "files": [ + "archive/v1/data/proof/verify.py", + "archive/v1/data/proof/expected_features.sha256", + "archive/v1/data/proof/sample_csi_data.json", + ".github/workflows/verify-pipeline.yml" + ], + "require": ["VERDICT", "expected_features.sha256", "verify.py"], + "rationale": "verify.py feeds a seeded reference signal through the production CSI pipeline and SHA-256-hashes the output; expected_features.sha256 pins it; verify-pipeline.yml re-runs it on every PR. Losing any of these removes the project's tamper-evidence guarantee (ADR-028).", + "ref": "docs/adr/ADR-028-esp32-capability-audit.md" + }, + { + "id": "ADR-028-witness-bundle", + "title": "Release-time witness bundle generator + self-verification script", + "files": ["scripts/generate-witness-bundle.sh"], + "require": ["VERIFY.sh", "witness-bundle"], + "rationale": "scripts/generate-witness-bundle.sh produces the self-contained, recipient-verifiable witness bundle (witness log + proof + test results + firmware hashes + VERIFY.sh). Part of the ADR-028 attestation chain.", + "ref": "docs/WITNESS-LOG-028.md" + } + ] +}