Spaces:
Sleeping
Sleeping
Commit ·
8bccaf2
1
Parent(s): 22e3bdb
docs(plan): error/risk audit framework implementation plan
Browse files7 tasks, TDD, ~25 checks across 5 tiers + self-verifying selftest. Local
commit only — not pushed (no redeploy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs/superpowers/plans/2026-05-17-error-audit-framework.md
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Error / Risk Audit Framework — Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** Build a single runnable, self-verifying `audit/` framework that mechanically catches every known error/risk class and tests all functionality before commit/deploy.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Modular Python package `audit/`. One pure function per check, decorated with `@register(id, tier, title)`, returning a `Result(status, evidence, remediation)`. A runner selects tiers, prints a table, exits non-zero on any FAIL. A `--selftest` mode proves every check fails on a deliberately-broken fixture (so the auditor can't be silently broken). Read-only against production.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3.11 stdlib only (subprocess/pathlib/ast/json/urllib), the repo's `.venv` pytest, `ruff`, `npm`, the existing playwright-skill for E2E.
|
| 10 |
+
|
| 11 |
+
**Spec:** `docs/superpowers/specs/2026-05-17-error-audit-framework-design.md`
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## File Structure
|
| 16 |
+
|
| 17 |
+
| File | Responsibility |
|
| 18 |
+
| --- | --- |
|
| 19 |
+
| `audit/__init__.py` | empty package marker |
|
| 20 |
+
| `audit/core.py` | `Status`, `Result`, `Check`, `register`, `CHECKS`, `git()`/`sh()` helpers, `run()`, `selftest()`, tier map, `REPO` |
|
| 21 |
+
| `audit/__main__.py` | CLI: arg parse → `run()`/`selftest()`; exit code |
|
| 22 |
+
| `audit/tier1_repo.py` | T1.1–T1.5 (repo integrity) |
|
| 23 |
+
| `audit/tier2_code.py` | T2.1–T2.6 (code soundness) |
|
| 24 |
+
| `audit/tier3_build.py` | T3.1–T3.3 (build & test gates) |
|
| 25 |
+
| `audit/tier4_functional.py` | T4-smoke + T4-e2e |
|
| 26 |
+
| `audit/tier5_deploy.py` | T5.1–T5.4 (deploy safety, read-only) |
|
| 27 |
+
| `audit/selftest_fixtures.py` | one broken-input factory per check id |
|
| 28 |
+
| `tools/audit` | thin executable entrypoint (`exec .venv/bin/python -m audit "$@"`) |
|
| 29 |
+
| `.githooks/pre-commit` | optional: `tools/audit --static` |
|
| 30 |
+
| `.githooks/pre-push` | optional: `tools/audit --build` + T5.1/T5.2 |
|
| 31 |
+
| `tests/test_audit_selftest.py` | runs `audit.selftest()` so the auditor is in the 215-green pytest gate |
|
| 32 |
+
| `audit/README.md` | usage |
|
| 33 |
+
|
| 34 |
+
Checks are grouped one module per tier (files that change together live together). Each check is independently testable via its selftest fixture.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## Task 1: Core contract + runner + CLI
|
| 39 |
+
|
| 40 |
+
**Files:**
|
| 41 |
+
- Create: `audit/__init__.py`, `audit/core.py`, `audit/__main__.py`, `tools/audit`
|
| 42 |
+
- Test: `tests/test_audit_selftest.py` (created here, expanded in Task 7)
|
| 43 |
+
|
| 44 |
+
- [ ] **Step 1: Write the failing test** — `tests/test_audit_selftest.py`
|
| 45 |
+
|
| 46 |
+
```python
|
| 47 |
+
import subprocess, sys, pathlib
|
| 48 |
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
| 49 |
+
|
| 50 |
+
def test_core_runner_passes_a_trivial_pass_check():
|
| 51 |
+
# audit.core.run with only a dummy PASS check exits 0
|
| 52 |
+
code = subprocess.run(
|
| 53 |
+
[sys.executable, "-c",
|
| 54 |
+
"import audit.core as c; "
|
| 55 |
+
"c.CHECKS.clear(); "
|
| 56 |
+
"c.register('X.1','static','dummy')(lambda: c.Result('X.1', c.Status.PASS, 'ok')); "
|
| 57 |
+
"import sys; sys.exit(c.run({'static'}))"],
|
| 58 |
+
cwd=REPO).returncode
|
| 59 |
+
assert code == 0
|
| 60 |
+
|
| 61 |
+
def test_core_runner_fails_on_a_fail_check():
|
| 62 |
+
code = subprocess.run(
|
| 63 |
+
[sys.executable, "-c",
|
| 64 |
+
"import audit.core as c; "
|
| 65 |
+
"c.CHECKS.clear(); "
|
| 66 |
+
"c.register('X.2','static','dummy')(lambda: c.Result('X.2', c.Status.FAIL, 'bad')); "
|
| 67 |
+
"import sys; sys.exit(c.run({'static'}))"],
|
| 68 |
+
cwd=REPO).returncode
|
| 69 |
+
assert code == 1
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
- [ ] **Step 2: Run test to verify it fails**
|
| 73 |
+
|
| 74 |
+
Run: `.venv/bin/python -m pytest tests/test_audit_selftest.py -q`
|
| 75 |
+
Expected: FAIL — `ModuleNotFoundError: No module named 'audit'`
|
| 76 |
+
|
| 77 |
+
- [ ] **Step 3: Write `audit/__init__.py`** — empty file.
|
| 78 |
+
|
| 79 |
+
- [ ] **Step 4: Write `audit/core.py`**
|
| 80 |
+
|
| 81 |
+
```python
|
| 82 |
+
"""Audit framework core: check contract, registry, runner, selftest."""
|
| 83 |
+
from __future__ import annotations
|
| 84 |
+
import dataclasses, enum, json, pathlib, subprocess, sys
|
| 85 |
+
from typing import Callable
|
| 86 |
+
|
| 87 |
+
REPO = pathlib.Path(__file__).resolve().parent.parent
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class Status(enum.Enum):
|
| 91 |
+
PASS = "PASS"
|
| 92 |
+
WARN = "WARN"
|
| 93 |
+
FAIL = "FAIL"
|
| 94 |
+
SKIP = "SKIP"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@dataclasses.dataclass
|
| 98 |
+
class Result:
|
| 99 |
+
check_id: str
|
| 100 |
+
status: Status
|
| 101 |
+
evidence: str
|
| 102 |
+
remediation: str = ""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclasses.dataclass
|
| 106 |
+
class Check:
|
| 107 |
+
id: str
|
| 108 |
+
tier: str # static | build | functional | deploy
|
| 109 |
+
title: str
|
| 110 |
+
fn: Callable[[], Result]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
CHECKS: list[Check] = []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def register(id: str, tier: str, title: str):
|
| 117 |
+
def deco(fn: Callable[[], Result]) -> Callable[[], Result]:
|
| 118 |
+
CHECKS.append(Check(id, tier, title, fn))
|
| 119 |
+
return fn
|
| 120 |
+
return deco
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
TIER_SETS = {
|
| 124 |
+
"static": {"static"},
|
| 125 |
+
"build": {"static", "build"},
|
| 126 |
+
"functional": {"static", "build", "functional"},
|
| 127 |
+
"deploy": {"deploy"},
|
| 128 |
+
"all": {"static", "build", "functional", "deploy"},
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def sh(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
|
| 133 |
+
return subprocess.run(cmd, cwd=REPO, capture_output=True, text=True, timeout=timeout)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def git(*args: str) -> str:
|
| 137 |
+
return sh(["git", *args]).stdout.strip()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _load_all_checks() -> None:
|
| 141 |
+
from audit import (tier1_repo, tier2_code, tier3_build, # noqa: F401
|
| 142 |
+
tier4_functional, tier5_deploy)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def run(selected: set[str], as_json: bool = False) -> int:
|
| 146 |
+
if not CHECKS:
|
| 147 |
+
_load_all_checks()
|
| 148 |
+
rows = []
|
| 149 |
+
for c in sorted(CHECKS, key=lambda c: c.id):
|
| 150 |
+
if c.tier not in selected:
|
| 151 |
+
continue
|
| 152 |
+
try:
|
| 153 |
+
r = c.fn()
|
| 154 |
+
except Exception as e: # a broken check is a FAIL, never silent
|
| 155 |
+
r = Result(c.id, Status.FAIL, f"check raised {type(e).__name__}: {e}",
|
| 156 |
+
"fix the check or the underlying issue")
|
| 157 |
+
rows.append((c, r))
|
| 158 |
+
fails = [r for _, r in rows if r.status is Status.FAIL]
|
| 159 |
+
if as_json:
|
| 160 |
+
print(json.dumps([{"id": c.id, "status": r.status.value,
|
| 161 |
+
"evidence": r.evidence, "remediation": r.remediation}
|
| 162 |
+
for c, r in rows], indent=2))
|
| 163 |
+
else:
|
| 164 |
+
for c, r in rows:
|
| 165 |
+
mark = {"PASS": "✓", "WARN": "▲", "FAIL": "✗", "SKIP": "·"}[r.status.value]
|
| 166 |
+
print(f" {mark} [{c.id}] {c.title}: {r.status.value}")
|
| 167 |
+
if r.status in (Status.FAIL, Status.WARN):
|
| 168 |
+
print(f" {r.evidence}")
|
| 169 |
+
if r.remediation:
|
| 170 |
+
print(f" fix: {r.remediation}")
|
| 171 |
+
n = len(rows)
|
| 172 |
+
print(f"\n {n} checks · "
|
| 173 |
+
f"{sum(1 for _,r in rows if r.status is Status.PASS)} pass · "
|
| 174 |
+
f"{sum(1 for _,r in rows if r.status is Status.WARN)} warn · "
|
| 175 |
+
f"{len(fails)} fail · "
|
| 176 |
+
f"{sum(1 for _,r in rows if r.status is Status.SKIP)} skip")
|
| 177 |
+
return 1 if fails else 0
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def selftest() -> int:
|
| 181 |
+
"""Every check must FAIL on its deliberately-broken fixture."""
|
| 182 |
+
from audit.selftest_fixtures import FIXTURES
|
| 183 |
+
if not CHECKS:
|
| 184 |
+
_load_all_checks()
|
| 185 |
+
bad = []
|
| 186 |
+
for c in CHECKS:
|
| 187 |
+
fx = FIXTURES.get(c.id)
|
| 188 |
+
if fx is None:
|
| 189 |
+
bad.append(f"{c.id}: NO selftest fixture")
|
| 190 |
+
continue
|
| 191 |
+
with fx() as broken_ctx:
|
| 192 |
+
r = c.fn()
|
| 193 |
+
if r.status is not Status.FAIL:
|
| 194 |
+
bad.append(f"{c.id}: expected FAIL on broken fixture, got {r.status.value}")
|
| 195 |
+
for b in bad:
|
| 196 |
+
print(f" ✗ {b}")
|
| 197 |
+
print(f"\n selftest: {len(CHECKS)} checks · {len(bad)} not self-verifying")
|
| 198 |
+
return 1 if bad else 0
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
- [ ] **Step 5: Write `audit/__main__.py`**
|
| 202 |
+
|
| 203 |
+
```python
|
| 204 |
+
import argparse, sys
|
| 205 |
+
from audit import core
|
| 206 |
+
|
| 207 |
+
def main() -> int:
|
| 208 |
+
p = argparse.ArgumentParser(prog="python -m audit")
|
| 209 |
+
g = p.add_mutually_exclusive_group()
|
| 210 |
+
for t in ("static", "build", "functional", "deploy", "all"):
|
| 211 |
+
g.add_argument(f"--{t}", action="store_const", const=t, dest="tier")
|
| 212 |
+
p.add_argument("--selftest", action="store_true")
|
| 213 |
+
p.add_argument("--json", action="store_true")
|
| 214 |
+
a = p.parse_args()
|
| 215 |
+
if a.selftest:
|
| 216 |
+
return core.selftest()
|
| 217 |
+
return core.run(core.TIER_SETS[a.tier or "all"], as_json=a.json)
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
sys.exit(main())
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
- [ ] **Step 6: Write `tools/audit`**
|
| 224 |
+
|
| 225 |
+
```bash
|
| 226 |
+
#!/usr/bin/env bash
|
| 227 |
+
# Entrypoint for the audit framework. Usage: tools/audit [--static|--build|--functional|--deploy|--all|--selftest] [--json]
|
| 228 |
+
set -euo pipefail
|
| 229 |
+
cd "$(dirname "$0")/.."
|
| 230 |
+
exec .venv/bin/python -m audit "$@"
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
Then: `chmod +x tools/audit`
|
| 234 |
+
|
| 235 |
+
- [ ] **Step 7: Create empty stub modules so `_load_all_checks` imports cleanly**
|
| 236 |
+
|
| 237 |
+
Create `audit/tier1_repo.py`, `audit/tier2_code.py`, `audit/tier3_build.py`, `audit/tier4_functional.py`, `audit/tier5_deploy.py`, `audit/selftest_fixtures.py` each containing only:
|
| 238 |
+
|
| 239 |
+
```python
|
| 240 |
+
# filled in a later task
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
And in `audit/selftest_fixtures.py`:
|
| 244 |
+
|
| 245 |
+
```python
|
| 246 |
+
FIXTURES: dict = {}
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
- [ ] **Step 8: Run test to verify it passes**
|
| 250 |
+
|
| 251 |
+
Run: `.venv/bin/python -m pytest tests/test_audit_selftest.py -q`
|
| 252 |
+
Expected: PASS (2 passed)
|
| 253 |
+
|
| 254 |
+
- [ ] **Step 9: Commit**
|
| 255 |
+
|
| 256 |
+
```bash
|
| 257 |
+
git add audit/ tools/audit tests/test_audit_selftest.py
|
| 258 |
+
git commit -m "feat(audit): core contract + runner + CLI"
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## Task 2: Tier 1 — Repo integrity (T1.1–T1.5)
|
| 264 |
+
|
| 265 |
+
**Files:**
|
| 266 |
+
- Modify: `audit/tier1_repo.py`, `audit/selftest_fixtures.py`
|
| 267 |
+
|
| 268 |
+
- [ ] **Step 1: Write `audit/tier1_repo.py`**
|
| 269 |
+
|
| 270 |
+
```python
|
| 271 |
+
"""Tier 1 — repo integrity (pre-commit, fast)."""
|
| 272 |
+
from __future__ import annotations
|
| 273 |
+
import re
|
| 274 |
+
from audit.core import register, Result, Status, REPO, git, sh
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@register("T1.1", "static", "no tracked symlinks")
|
| 278 |
+
def t1_1() -> Result:
|
| 279 |
+
out = git("ls-files", "-s")
|
| 280 |
+
syms = [ln.split("\t", 1)[1] for ln in out.splitlines() if ln.startswith("120000")]
|
| 281 |
+
if syms:
|
| 282 |
+
return Result("T1.1", Status.FAIL, f"tracked symlinks: {syms}",
|
| 283 |
+
"git rm --cached <path>; add to .gitignore (no trailing slash)")
|
| 284 |
+
return Result("T1.1", Status.PASS, "no tracked symlinks")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@register("T1.2", "static", "LFS coverage for binary/large files")
|
| 288 |
+
def t1_2() -> Result:
|
| 289 |
+
ga = (REPO / ".gitattributes").read_text(encoding="utf-8", errors="replace")
|
| 290 |
+
lfs_globs = [ln.split()[0] for ln in ga.splitlines()
|
| 291 |
+
if "filter=lfs" in ln and ln.strip() and not ln.startswith("#")]
|
| 292 |
+
lfs_files = set(sh(["git", "lfs", "ls-files", "-n"]).stdout.split())
|
| 293 |
+
bad = []
|
| 294 |
+
for ln in git("ls-files", "-s").splitlines():
|
| 295 |
+
mode, _, _, path = ln.replace("\t", " ").split(maxsplit=3)
|
| 296 |
+
if mode == "120000":
|
| 297 |
+
continue
|
| 298 |
+
blob_sz = sh(["git", "cat-file", "-s", ln.split()[1]]).stdout.strip()
|
| 299 |
+
is_lfs = path in lfs_files
|
| 300 |
+
big = blob_sz.isdigit() and int(blob_sz) > 512 * 1024
|
| 301 |
+
if big and not is_lfs:
|
| 302 |
+
bad.append(f"{path} ({int(blob_sz)//1024} KB) not LFS")
|
| 303 |
+
if bad:
|
| 304 |
+
return Result("T1.2", Status.FAIL, "; ".join(bad[:8]),
|
| 305 |
+
"add a filter=lfs rule to .gitattributes; git rm --cached + re-add the files")
|
| 306 |
+
return Result("T1.2", Status.PASS, f"{len(lfs_files)} LFS files; no oversized non-LFS blobs")
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@register("T1.3", "static", "no real secrets tracked")
|
| 310 |
+
def t1_3() -> Result:
|
| 311 |
+
KEYISH = re.compile(r"(hf_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_\-]{20,}|xox[bp]-[A-Za-z0-9-]{20,})")
|
| 312 |
+
suspects = []
|
| 313 |
+
for path in git("ls-files").splitlines():
|
| 314 |
+
base = path.rsplit("/", 1)[-1]
|
| 315 |
+
if base == ".env" or (base.startswith(".env") and not base.endswith((".example", ".sample"))):
|
| 316 |
+
suspects.append(f"{path} (real dotenv tracked)")
|
| 317 |
+
continue
|
| 318 |
+
if path.endswith((".png", ".jpg", ".pdf", ".duckdb", ".bin", ".ico", ".woff", ".woff2", ".ttf")):
|
| 319 |
+
continue
|
| 320 |
+
try:
|
| 321 |
+
txt = (REPO / path).read_text(encoding="utf-8", errors="ignore")
|
| 322 |
+
except Exception:
|
| 323 |
+
continue
|
| 324 |
+
if KEYISH.search(txt) and "example" not in path and "ADR-010" not in path:
|
| 325 |
+
suspects.append(f"{path} (key-shaped string)")
|
| 326 |
+
if suspects:
|
| 327 |
+
return Result("T1.3", Status.FAIL, "; ".join(suspects[:8]),
|
| 328 |
+
"remove from index + history; rotate the key; gitignore the file")
|
| 329 |
+
return Result("T1.3", Status.PASS, "no real .env / key material tracked")
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
@register("T1.4", "static", ".gitignore robust for file AND dir")
|
| 333 |
+
def t1_4() -> Result:
|
| 334 |
+
intents = ["tools/.pdf_text_cache", "rag/corpus", "rag/extracted", "rag/vectors"]
|
| 335 |
+
gi = (REPO / ".gitignore").read_text(encoding="utf-8", errors="replace").splitlines()
|
| 336 |
+
missing = []
|
| 337 |
+
for it in intents:
|
| 338 |
+
# a slash-less line ignores BOTH the file and the dir form
|
| 339 |
+
if it not in gi:
|
| 340 |
+
missing.append(it)
|
| 341 |
+
if missing:
|
| 342 |
+
return Result("T1.4", Status.FAIL, f"only dir-form (or absent) ignore for: {missing}",
|
| 343 |
+
"add a slash-less line per intent so a symlink/file of that name is also ignored")
|
| 344 |
+
return Result("T1.4", Status.PASS, "file+dir ignore intents present")
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
@register("T1.5", "static", "no junk/build artifacts tracked")
|
| 348 |
+
def t1_5() -> Result:
|
| 349 |
+
JUNK = ("tools/.pdf_text_cache/", ".pytest_cache/", ".DS_Store",
|
| 350 |
+
"frontend/out/", "frontend/.next/", "node_modules/", ".tsbuildinfo")
|
| 351 |
+
tracked = git("ls-files").splitlines()
|
| 352 |
+
hits = [p for p in tracked if any(j in p for j in JUNK)]
|
| 353 |
+
if hits:
|
| 354 |
+
return Result("T1.5", Status.FAIL, f"{len(hits)} junk paths e.g. {hits[:5]}",
|
| 355 |
+
"git rm -r --cached <path> and gitignore it")
|
| 356 |
+
return Result("T1.5", Status.PASS, "no caches/build artifacts tracked")
|
| 357 |
+
```
|
| 358 |
+
|
| 359 |
+
- [ ] **Step 2: Add the 5 selftest fixtures** — append to `audit/selftest_fixtures.py`
|
| 360 |
+
|
| 361 |
+
```python
|
| 362 |
+
import contextlib, pathlib, subprocess
|
| 363 |
+
from audit.core import REPO
|
| 364 |
+
|
| 365 |
+
@contextlib.contextmanager
|
| 366 |
+
def _staged_symlink():
|
| 367 |
+
p = REPO / "_audit_selftest_link"
|
| 368 |
+
p.symlink_to("/nonexistent")
|
| 369 |
+
subprocess.run(["git", "add", "-f", str(p)], cwd=REPO)
|
| 370 |
+
try:
|
| 371 |
+
yield
|
| 372 |
+
finally:
|
| 373 |
+
subprocess.run(["git", "rm", "-f", "--cached", "-q", str(p)], cwd=REPO)
|
| 374 |
+
p.unlink(missing_ok=True)
|
| 375 |
+
|
| 376 |
+
# (Analogous tiny context managers for T1.2..T1.5: stage an oversized non-LFS
|
| 377 |
+
# blob; a file containing 'hf_' + 40 chars; remove the rag/corpus gitignore
|
| 378 |
+
# line in a temp copy; stage a .DS_Store. Each yields a broken repo state and
|
| 379 |
+
# restores it. Full code written in this step — no placeholders.)
|
| 380 |
+
FIXTURES.update({
|
| 381 |
+
"T1.1": _staged_symlink,
|
| 382 |
+
# "T1.2": _staged_big_blob, "T1.3": _staged_fake_key,
|
| 383 |
+
# "T1.4": _broken_gitignore, "T1.5": _staged_dsstore
|
| 384 |
+
})
|
| 385 |
+
```
|
| 386 |
+
|
| 387 |
+
> Implementer note: write the four remaining context managers in full here using the same `@contextlib.contextmanager` + git add/rm + restore pattern as `_staged_symlink`. They are 6–10 lines each; do not abbreviate in the actual code.
|
| 388 |
+
|
| 389 |
+
- [ ] **Step 3: Verify Tier 1 runs**
|
| 390 |
+
|
| 391 |
+
Run: `.venv/bin/python -m audit --static`
|
| 392 |
+
Expected: T1.1–T1.5 all `✓ PASS` on the current clean tree.
|
| 393 |
+
|
| 394 |
+
- [ ] **Step 4: Verify selftest catches breakage**
|
| 395 |
+
|
| 396 |
+
Run: `.venv/bin/python -m audit --selftest 2>&1 | grep -E 'T1\.'`
|
| 397 |
+
Expected: no `T1.x: ...not self-verifying` lines (each FAILs on its broken fixture).
|
| 398 |
+
|
| 399 |
+
- [ ] **Step 5: Commit**
|
| 400 |
+
|
| 401 |
+
```bash
|
| 402 |
+
git add audit/tier1_repo.py audit/selftest_fixtures.py
|
| 403 |
+
git commit -m "feat(audit): Tier 1 repo-integrity checks + selftest fixtures"
|
| 404 |
+
```
|
| 405 |
+
|
| 406 |
+
---
|
| 407 |
+
|
| 408 |
+
## Task 3: Tier 2 — Code soundness (T2.1–T2.6)
|
| 409 |
+
|
| 410 |
+
**Files:** Modify `audit/tier2_code.py`, `audit/selftest_fixtures.py`
|
| 411 |
+
|
| 412 |
+
- [ ] **Step 1: Write `audit/tier2_code.py`** (full code)
|
| 413 |
+
|
| 414 |
+
```python
|
| 415 |
+
"""Tier 2 — code soundness (pre-commit)."""
|
| 416 |
+
from __future__ import annotations
|
| 417 |
+
import ast, re, subprocess, sys
|
| 418 |
+
from audit.core import register, Result, Status, REPO, git, sh
|
| 419 |
+
|
| 420 |
+
PY = [p for p in git("ls-files").splitlines() if p.endswith(".py")]
|
| 421 |
+
DEAD = ("backend.orchestrator", "import sales_brain", "qa_brain", "faithfulness",
|
| 422 |
+
"backend.translator", "profile_extractor", "get_judge_llm", "get_fast_brain_llm")
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@register("T2.1", "static", "all .py parse (AST)")
|
| 426 |
+
def t2_1() -> Result:
|
| 427 |
+
bad = []
|
| 428 |
+
for p in PY:
|
| 429 |
+
try:
|
| 430 |
+
ast.parse((REPO / p).read_text(encoding="utf-8", errors="replace"), p)
|
| 431 |
+
except SyntaxError as e:
|
| 432 |
+
bad.append(f"{p}: {e}")
|
| 433 |
+
return (Result("T2.1", Status.FAIL, "; ".join(bad[:5]), "fix the syntax error")
|
| 434 |
+
if bad else Result("T2.1", Status.PASS, f"{len(PY)} files parse"))
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
@register("T2.2", "static", "runtime-import every backend/rag module")
|
| 438 |
+
def t2_2() -> Result:
|
| 439 |
+
mods = []
|
| 440 |
+
for p in PY:
|
| 441 |
+
if (p.startswith("backend/") or p.startswith("rag/")) and not p.endswith("__init__.py") \
|
| 442 |
+
and "_smoke_test" not in p and "/tests/" not in p:
|
| 443 |
+
mods.append(p[:-3].replace("/", "."))
|
| 444 |
+
code = "import importlib,sys\nbad=[]\n" + \
|
| 445 |
+
"".join(f"try:\n importlib.import_module({m!r})\nexcept Exception as e:\n bad.append(({m!r},repr(e)))\n"
|
| 446 |
+
for m in mods) + "print(bad)\nsys.exit(1 if bad else 0)"
|
| 447 |
+
r = sh([".venv/bin/python", "-c", code], timeout=300)
|
| 448 |
+
if r.returncode != 0:
|
| 449 |
+
return Result("T2.2", Status.FAIL, r.stdout.strip()[:600],
|
| 450 |
+
"fix the import (often: import wrongly placed inside a docstring)")
|
| 451 |
+
return Result("T2.2", Status.PASS, f"{len(mods)} modules import clean")
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
@register("T2.3", "static", "no refs to deleted modules/symbols")
|
| 455 |
+
def t2_3() -> Result:
|
| 456 |
+
code_hits, doc_hits = [], []
|
| 457 |
+
for p in PY:
|
| 458 |
+
for i, ln in enumerate(open(REPO / p, encoding="utf-8", errors="replace"), 1):
|
| 459 |
+
for d in DEAD:
|
| 460 |
+
if d in ln:
|
| 461 |
+
s = ln.strip()
|
| 462 |
+
(doc_hits if s.startswith("#") or s.startswith(('"', "'", "*")) else code_hits
|
| 463 |
+
).append(f"{p}:{i} {d}")
|
| 464 |
+
if code_hits:
|
| 465 |
+
return Result("T2.3", Status.FAIL, "; ".join(code_hits[:6]),
|
| 466 |
+
"remove/replace the dead reference (e.g. get_judge_llm → get_brain_llm)")
|
| 467 |
+
if doc_hits:
|
| 468 |
+
return Result("T2.3", Status.WARN, f"{len(doc_hits)} stale comment refs e.g. {doc_hits[:3]}",
|
| 469 |
+
"tidy the stale comment")
|
| 470 |
+
return Result("T2.3", Status.PASS, "no dead-symbol references")
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
@register("T2.4", "static", "no */ inside CSS/JS block-comment body")
|
| 474 |
+
def t2_4() -> Result:
|
| 475 |
+
bad = []
|
| 476 |
+
for p in git("ls-files").splitlines():
|
| 477 |
+
if not p.endswith((".css", ".scss")):
|
| 478 |
+
continue
|
| 479 |
+
txt = (REPO / p).read_text(encoding="utf-8", errors="replace")
|
| 480 |
+
for m in re.finditer(r"/\*.*?\*/", txt, re.S):
|
| 481 |
+
body = m.group(0)[2:-2]
|
| 482 |
+
if "*/" in body:
|
| 483 |
+
bad.append(f"{p}: nested */ in comment")
|
| 484 |
+
return (Result("T2.4", Status.FAIL, "; ".join(bad), "space the token: '* /' or reword")
|
| 485 |
+
if bad else Result("T2.4", Status.PASS, "no comment-terminator footgun"))
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@register("T2.5", "static", "no hardcoded 40-data path construction")
|
| 489 |
+
def t2_5() -> Result:
|
| 490 |
+
pat = re.compile(r'/\s*["\']40-data["\']')
|
| 491 |
+
bad = []
|
| 492 |
+
for p in PY:
|
| 493 |
+
if not (p.startswith("backend/") or p.startswith("rag/")):
|
| 494 |
+
continue
|
| 495 |
+
if p.endswith("config.py"):
|
| 496 |
+
continue
|
| 497 |
+
for i, ln in enumerate(open(REPO / p, encoding="utf-8", errors="replace"), 1):
|
| 498 |
+
if pat.search(ln) and not ln.strip().startswith("#"):
|
| 499 |
+
bad.append(f"{p}:{i}")
|
| 500 |
+
return (Result("T2.5", Status.FAIL, "; ".join(bad[:8]), "use settings.DATA_DIR")
|
| 501 |
+
if bad else Result("T2.5", Status.PASS, "DATA_DIR centralized"))
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@register("T2.6", "static", "ruff + tsc clean")
|
| 505 |
+
def t2_6() -> Result:
|
| 506 |
+
ruff = sh([".venv/bin/ruff", "check", "backend", "rag", "audit"], timeout=120)
|
| 507 |
+
tsc = sh(["npx", "--prefix", "frontend", "--no-install", "tsc", "-p", "frontend", "--noEmit"], timeout=240)
|
| 508 |
+
probs = []
|
| 509 |
+
if ruff.returncode not in (0, 127):
|
| 510 |
+
probs.append("ruff: " + (ruff.stdout or ruff.stderr).strip().splitlines()[-1][:200])
|
| 511 |
+
if tsc.returncode not in (0, 127):
|
| 512 |
+
probs.append("tsc: " + (tsc.stdout or tsc.stderr).strip().splitlines()[-1][:200])
|
| 513 |
+
if 127 in (ruff.returncode, tsc.returncode):
|
| 514 |
+
return Result("T2.6", Status.SKIP, "ruff/tsc not installed", "pip install ruff / npm i")
|
| 515 |
+
return (Result("T2.6", Status.FAIL, " | ".join(probs), "fix lint/type errors")
|
| 516 |
+
if probs else Result("T2.6", Status.PASS, "ruff + tsc clean"))
|
| 517 |
+
```
|
| 518 |
+
|
| 519 |
+
- [ ] **Step 2: Add T2.1–T2.6 selftest fixtures** to `audit/selftest_fixtures.py` — each a context manager that creates the broken condition (a temp `.py` with a SyntaxError; a temp module with an import inside its docstring; a temp file referencing `get_judge_llm`; a temp `.css` with `/* a */ b */`; a temp backend file with `/ "40-data" /`; monkeypatch ruff to fail). Full code, same pattern as Task 2; register all six in `FIXTURES`.
|
| 520 |
+
|
| 521 |
+
- [ ] **Step 3: Verify** — `.venv/bin/python -m audit --static` → T2.* PASS on clean tree.
|
| 522 |
+
- [ ] **Step 4: Selftest** — `.venv/bin/python -m audit --selftest` → no T2 self-verify gaps.
|
| 523 |
+
- [ ] **Step 5: Commit** — `git add audit/tier2_code.py audit/selftest_fixtures.py && git commit -m "feat(audit): Tier 2 code-soundness checks"`
|
| 524 |
+
|
| 525 |
+
---
|
| 526 |
+
|
| 527 |
+
## Task 4: Tier 3 — Build & test gates (T3.1–T3.3)
|
| 528 |
+
|
| 529 |
+
**Files:** Modify `audit/tier3_build.py`, `audit/selftest_fixtures.py`
|
| 530 |
+
|
| 531 |
+
- [ ] **Step 1: Write `audit/tier3_build.py`** (full code)
|
| 532 |
+
|
| 533 |
+
```python
|
| 534 |
+
"""Tier 3 — build & test gates."""
|
| 535 |
+
from __future__ import annotations
|
| 536 |
+
import re, urllib.request
|
| 537 |
+
from audit.core import register, Result, Status, sh
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
@register("T3.1", "build", "pytest green (clean-clone scoping)")
|
| 541 |
+
def t3_1() -> Result:
|
| 542 |
+
r = sh([".venv/bin/python", "-m", "pytest", "-q"], timeout=600)
|
| 543 |
+
tail = (r.stdout + r.stderr).strip().splitlines()[-1] if (r.stdout or r.stderr) else ""
|
| 544 |
+
if r.returncode != 0 or "error" in tail.lower() or "failed" in tail.lower():
|
| 545 |
+
return Result("T3.1", Status.FAIL, tail[:200], "fix failing tests / collection error")
|
| 546 |
+
m = re.search(r"(\d+) passed", tail)
|
| 547 |
+
return Result("T3.1", Status.PASS, f"{m.group(0) if m else 'green'}")
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
@register("T3.2", "build", "next build (production static export)")
|
| 551 |
+
def t3_2() -> Result:
|
| 552 |
+
r = sh(["npm", "--prefix", "frontend", "run", "build"], timeout=900)
|
| 553 |
+
htmls = sh(["bash", "-lc", "ls frontend/out/*.html 2>/dev/null | wc -l"]).stdout.strip()
|
| 554 |
+
if r.returncode != 0:
|
| 555 |
+
return Result("T3.2", Status.FAIL,
|
| 556 |
+
(r.stdout + r.stderr).strip().splitlines()[-1][:200],
|
| 557 |
+
"fix the production build error")
|
| 558 |
+
if htmls in ("", "0"):
|
| 559 |
+
return Result("T3.2", Status.FAIL, "no static export emitted",
|
| 560 |
+
"ensure next.config has output:'export'")
|
| 561 |
+
return Result("T3.2", Status.PASS, f"build ok, {htmls} html exported")
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
@register("T3.3", "build", "backend boots + /api/health (local)")
|
| 565 |
+
def t3_3() -> Result:
|
| 566 |
+
# backend already runs locally on :8000 during dev; verify it imports + health
|
| 567 |
+
imp = sh([".venv/bin/python", "-c", "import backend.main"], timeout=120)
|
| 568 |
+
if imp.returncode != 0:
|
| 569 |
+
return Result("T3.3", Status.FAIL, imp.stderr.strip()[:200], "fix backend import")
|
| 570 |
+
try:
|
| 571 |
+
with urllib.request.urlopen("http://127.0.0.1:8000/api/health", timeout=8) as f:
|
| 572 |
+
body = f.read(200).decode()
|
| 573 |
+
ok = '"status":"ok"' in body
|
| 574 |
+
return (Result("T3.3", Status.PASS, "backend imports + /api/health ok") if ok
|
| 575 |
+
else Result("T3.3", Status.FAIL, body[:120], "backend unhealthy"))
|
| 576 |
+
except Exception:
|
| 577 |
+
return Result("T3.3", Status.SKIP, "no local backend on :8000",
|
| 578 |
+
"start uvicorn backend.main:app --port 8000 to run this check")
|
| 579 |
+
```
|
| 580 |
+
|
| 581 |
+
- [ ] **Step 2: Fixtures** for T3.1–T3.3 (a temp failing test file; force `next build` failure via a temp broken `.tsx` import; monkeypatch the health URL to an unreachable port → must FAIL not SKIP when import itself broken). Full code; register in `FIXTURES`.
|
| 582 |
+
- [ ] **Step 3: Verify** — `.venv/bin/python -m audit --build` (T3 may take minutes).
|
| 583 |
+
- [ ] **Step 4: Selftest** — no T3 self-verify gaps.
|
| 584 |
+
- [ ] **Step 5: Commit** — `git add audit/tier3_build.py audit/selftest_fixtures.py && git commit -m "feat(audit): Tier 3 build/test gates"`
|
| 585 |
+
|
| 586 |
+
---
|
| 587 |
+
|
| 588 |
+
## Task 5: Tier 4 — Functionality, both sub-tiers (T4-smoke, T4-e2e)
|
| 589 |
+
|
| 590 |
+
**Files:** Modify `audit/tier4_functional.py`, `audit/selftest_fixtures.py`
|
| 591 |
+
Create: `audit/e2e/insurancebot_e2e.js` (Playwright journeys, driven via the playwright-skill runner)
|
| 592 |
+
|
| 593 |
+
- [ ] **Step 1: Write `audit/tier4_functional.py`** — `T4.SMOKE` (one Result per endpoint group; each does a single local HTTP call to :8000 and asserts shape/sane counts: health, version, coverage≈148/20/~7300±5%, chat one turn returns non-error reply, upload-policy accepts a real corpus PDF → chunks_added>0 and rejects 100 bytes of junk, profile, scorecard, session/clear) and `T4.E2E` (shells the playwright-skill runner on `audit/e2e/insurancebot_e2e.js`, parses its `RJSON`, asserts: chat→inline cards, marketplace renders, compare modal opens, profile→premium recompute, voice copy touch-vs-desktop, PDF-upload UI ack, no console errors, no 390px overflow). Both return `SKIP` (not PASS) when the local backend / Playwright is unavailable. Full code following the Tier-3 pattern.
|
| 594 |
+
|
| 595 |
+
- [ ] **Step 2: Write `audit/e2e/insurancebot_e2e.js`** — a single Playwright script (headless) running the enumerated journeys, emitting one `RJSON {...}` line of booleans, modelled on the verified `/tmp/pw-3-multiturn.js` from the recovery session (reuse its multi-turn fact-find→recommendation logic).
|
| 596 |
+
- [ ] **Step 3: Fixtures** — T4.SMOKE fixture monkeypatches the base URL to a stub returning wrong coverage counts (must FAIL); T4.E2E fixture feeds a canned `RJSON` with `cardsRenderInlineOnMobile:false` (must FAIL). Full code; register.
|
| 597 |
+
- [ ] **Step 4: Verify** — with local backend up: `.venv/bin/python -m audit --functional`.
|
| 598 |
+
- [ ] **Step 5: Selftest + Commit** — `git add audit/tier4_functional.py audit/e2e/ audit/selftest_fixtures.py && git commit -m "feat(audit): Tier 4 functionality (smoke + exhaustive E2E)"`
|
| 599 |
+
|
| 600 |
+
---
|
| 601 |
+
|
| 602 |
+
## Task 6: Tier 5 — Deploy safety, read-only (T5.1–T5.4)
|
| 603 |
+
|
| 604 |
+
**Files:** Modify `audit/tier5_deploy.py`, `audit/selftest_fixtures.py`
|
| 605 |
+
|
| 606 |
+
- [ ] **Step 1: Write `audit/tier5_deploy.py`** (full code)
|
| 607 |
+
|
| 608 |
+
```python
|
| 609 |
+
"""Tier 5 — deploy safety. READ-ONLY against production (GET + git ls-remote only)."""
|
| 610 |
+
from __future__ import annotations
|
| 611 |
+
import json, urllib.request
|
| 612 |
+
from audit.core import register, Result, Status, REPO, git, sh
|
| 613 |
+
|
| 614 |
+
SPACE_API = "https://huggingface.co/api/spaces/rohitsar567/InsuranceBot"
|
| 615 |
+
LIVE = "https://rohitsar567-insurancebot.hf.space"
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
@register("T5.1", "deploy", "LFS pre-push validation (HF hook simulation)")
|
| 619 |
+
def t5_1() -> Result:
|
| 620 |
+
ga = (REPO / ".gitattributes").read_text("utf-8", "replace")
|
| 621 |
+
globs = [l.split()[0] for l in ga.splitlines() if "filter=lfs" in l and not l.startswith("#")]
|
| 622 |
+
lfs = set(sh(["git", "lfs", "ls-files", "-n"]).stdout.split())
|
| 623 |
+
import fnmatch
|
| 624 |
+
bad = [p for p in git("ls-files").splitlines()
|
| 625 |
+
if any(fnmatch.fnmatch(p, g) for g in globs) and p not in lfs]
|
| 626 |
+
return (Result("T5.1", Status.FAIL, f"would be HF-rejected: {bad[:6]}",
|
| 627 |
+
"git rm --cached + re-add so they store as LFS pointers")
|
| 628 |
+
if bad else Result("T5.1", Status.PASS, "all LFS-pattern files are pointers"))
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
@register("T5.2", "deploy", "Dockerfile coherence")
|
| 632 |
+
def t5_2() -> Result:
|
| 633 |
+
df = (REPO / "Dockerfile").read_text("utf-8", "replace")
|
| 634 |
+
missing = [m.split()[1] for m in df.splitlines()
|
| 635 |
+
if m.strip().startswith("COPY ")
|
| 636 |
+
and not (REPO / m.split()[1]).exists()]
|
| 637 |
+
collide = [p for p in git("ls-files").splitlines()
|
| 638 |
+
if p in ("rag/corpus", "rag/extracted", "rag/vectors")]
|
| 639 |
+
if missing or collide:
|
| 640 |
+
return Result("T5.2", Status.FAIL,
|
| 641 |
+
f"COPY missing: {missing}; hydration-collide: {collide}",
|
| 642 |
+
"fix COPY source / untrack rag/corpus|extracted|vectors")
|
| 643 |
+
return Result("T5.2", Status.PASS, "COPY paths exist; no hydration collision")
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
@register("T5.3", "deploy", "post-deploy guarded sha + live smoke")
|
| 647 |
+
def t5_3() -> Result:
|
| 648 |
+
head = git("rev-parse", "HEAD")[:12]
|
| 649 |
+
try:
|
| 650 |
+
rt = json.load(urllib.request.urlopen(SPACE_API, timeout=20))["runtime"]
|
| 651 |
+
sha = (rt.get("sha") or "")[:12]
|
| 652 |
+
if rt.get("stage") != "RUNNING":
|
| 653 |
+
return Result("T5.3", Status.WARN, f"Space stage={rt.get('stage')} sha={sha}",
|
| 654 |
+
"wait for build / check HF build log")
|
| 655 |
+
if sha != head:
|
| 656 |
+
return Result("T5.3", Status.WARN,
|
| 657 |
+
f"live sha {sha} != local HEAD {head} (not yet deployed?)",
|
| 658 |
+
"push + verify; never trust 'RUNNING' alone (LFS silent-failure)")
|
| 659 |
+
h = urllib.request.urlopen(f"{LIVE}/api/health", timeout=20).read(200).decode()
|
| 660 |
+
logo = urllib.request.urlopen(f"{LIVE}/insurer-logos/oriental-insurance.png", timeout=20)
|
| 661 |
+
ok = '"status":"ok"' in h and logo.headers.get_content_type() == "image/png"
|
| 662 |
+
return (Result("T5.3", Status.PASS, f"sha {sha} live, health ok, LFS logo image/png")
|
| 663 |
+
if ok else Result("T5.3", Status.FAIL, f"health/logo bad: {h[:80]}",
|
| 664 |
+
"investigate live deploy"))
|
| 665 |
+
except Exception as e:
|
| 666 |
+
return Result("T5.3", Status.SKIP, f"no network/API: {e}", "run with network to verify deploy")
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
@register("T5.4", "deploy", "standing tripwires (bloat/disk/stale-docs)")
|
| 670 |
+
def t5_4() -> Result:
|
| 671 |
+
warns = []
|
| 672 |
+
bloat = sh(["bash", "-lc",
|
| 673 |
+
"find rag -name link_lists.bin -size +200M 2>/dev/null; "
|
| 674 |
+
"du -sm rag/_hf_dataset_backup 2>/dev/null | awk '$1>20000{print}'"]).stdout.strip()
|
| 675 |
+
if bloat:
|
| 676 |
+
warns.append(f"chroma/backup bloat: {bloat}")
|
| 677 |
+
free = sh(["bash", "-lc", "df -m . | tail -1 | awk '{print $4}'"]).stdout.strip()
|
| 678 |
+
if free.isdigit() and int(free) < 2000:
|
| 679 |
+
warns.append(f"low disk: {free} MB free")
|
| 680 |
+
stale = sh(["bash", "-lc",
|
| 681 |
+
"grep -rl 'Status | Live' 70-docs 2>/dev/null | xargs -r grep -l 'orchestrator.py' 2>/dev/null"]).stdout.strip()
|
| 682 |
+
if stale:
|
| 683 |
+
warns.append(f"stale present-state docs: {stale.splitlines()[:3]}")
|
| 684 |
+
return (Result("T5.4", Status.WARN, " | ".join(warns), "address the flagged tripwire")
|
| 685 |
+
if warns else Result("T5.4", Status.PASS, "no bloat/disk/stale-doc tripwire"))
|
| 686 |
+
```
|
| 687 |
+
|
| 688 |
+
- [ ] **Step 2: Fixtures** — T5.1 stage a non-LFS file matching an LFS glob; T5.2 add a bogus `COPY nonexistent ./x` to a temp Dockerfile copy; T5.3 monkeypatch SPACE_API to a stub with mismatched sha (must WARN→treat as FAIL in selftest by asserting status≠PASS — fixture asserts not-PASS); T5.4 create an oversized dummy file. Full code; register. (Note: for WARN-only checks, the selftest asserts `status is not PASS` rather than strictly FAIL — add a `selftest_expect` field to `Check`/`register` defaulting to `FAIL`, set `WARN` for T2.3/T5.4/T5.3; update `core.selftest()` to honor it. Make this small core change here.)
|
| 689 |
+
- [ ] **Step 3: Verify** — `.venv/bin/python -m audit --deploy`.
|
| 690 |
+
- [ ] **Step 4: Selftest + Commit** — `git add audit/tier5_deploy.py audit/core.py audit/selftest_fixtures.py && git commit -m "feat(audit): Tier 5 deploy-safety (read-only) + WARN selftest expectation"`
|
| 691 |
+
|
| 692 |
+
---
|
| 693 |
+
|
| 694 |
+
## Task 7: Self-test wiring, git hooks, README, full run
|
| 695 |
+
|
| 696 |
+
**Files:** Modify `tests/test_audit_selftest.py`; Create `.githooks/pre-commit`, `.githooks/pre-push`, `audit/README.md`
|
| 697 |
+
|
| 698 |
+
- [ ] **Step 1: Extend `tests/test_audit_selftest.py`**
|
| 699 |
+
|
| 700 |
+
```python
|
| 701 |
+
def test_every_check_is_self_verifying():
|
| 702 |
+
import audit.core as c
|
| 703 |
+
assert c.selftest() == 0, "some checks are not self-verifying (see stdout)"
|
| 704 |
+
```
|
| 705 |
+
|
| 706 |
+
- [ ] **Step 2: Run** `.venv/bin/python -m pytest tests/test_audit_selftest.py -q` → PASS (now in the 215-gate; count becomes 216).
|
| 707 |
+
- [ ] **Step 3: Write `.githooks/pre-commit`**
|
| 708 |
+
|
| 709 |
+
```bash
|
| 710 |
+
#!/usr/bin/env bash
|
| 711 |
+
exec "$(git rev-parse --show-toplevel)/tools/audit" --static
|
| 712 |
+
```
|
| 713 |
+
|
| 714 |
+
`.githooks/pre-push`:
|
| 715 |
+
|
| 716 |
+
```bash
|
| 717 |
+
#!/usr/bin/env bash
|
| 718 |
+
root="$(git rev-parse --show-toplevel)"
|
| 719 |
+
"$root/tools/audit" --build || exit 1
|
| 720 |
+
"$root/.venv/bin/python" -m audit --deploy || exit 1
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
`chmod +x .githooks/*`. Document opt-in: `git config core.hooksPath .githooks`.
|
| 724 |
+
|
| 725 |
+
- [ ] **Step 4: Write `audit/README.md`** — usage table (tiers, when to run, exit codes, `--selftest`, hook opt-in).
|
| 726 |
+
- [ ] **Step 5: Full exhaustive run** — `tools/audit --all` (backend up). Capture the report; every check PASS or justified WARN/SKIP.
|
| 727 |
+
- [ ] **Step 6: Commit** — `git add tests/test_audit_selftest.py .githooks audit/README.md && git commit -m "feat(audit): selftest in pytest gate + git hooks + docs"`
|
| 728 |
+
|
| 729 |
+
---
|
| 730 |
+
|
| 731 |
+
## Self-Review
|
| 732 |
+
|
| 733 |
+
**Spec coverage:** T1.1–T1.5 → Task 2 · T2.1–T2.6 → Task 3 · T3.1–T3.3 → Task 4 · T4-smoke/T4-e2e → Task 5 · T5.1–T5.4 → Task 6 · self-verifying → Task 1 (`selftest`) + Task 7 (pytest wiring) · CLI/tiers/hooks → Task 1 + Task 7 · read-only-prod → Task 6 (GET/ls-remote only) · "test all functionalities" both sub-tiers → Task 5. No spec section unmapped.
|
| 734 |
+
|
| 735 |
+
**Placeholder scan:** Core/Tier1/Tier2/Tier3/Tier5 checks given as complete code. Tier-1 fixtures: `_staged_symlink` complete; the other four + Tier2/3/4/5 fixtures specified with exact pattern + explicit "write in full, do not abbreviate" instruction (they are mechanical 6–10-line repeats of the shown context-manager pattern — acceptable as a per-fixture instruction, not a logic placeholder). Tier-4 bodies are specified behaviourally with the exact endpoints/assertions + reuse of the verified `/tmp/pw-3-multiturn.js`; flagged as the one area to expand to full code during implementation.
|
| 736 |
+
|
| 737 |
+
**Type consistency:** `Result(check_id,status,evidence,remediation)`, `Status.{PASS,WARN,FAIL,SKIP}`, `register(id,tier,title)`, `CHECKS`, `TIER_SETS`, `git()/sh()`, `FIXTURES` dict — names identical across all tasks. Task 6 adds `selftest_expect` to `register`/`Check`/`selftest()` consistently in one place.
|
| 738 |
+
|
| 739 |
+
**Scope:** one cohesive framework, one repo, single plan. Good.
|
| 740 |
+
|
| 741 |
+
---
|
| 742 |
+
|
| 743 |
+
## Execution Handoff
|
| 744 |
+
|
| 745 |
+
(Provided after user reviews this plan.)
|