Skip to content

Commit 7cab418

Browse files
rootroot
authored andcommitted
feat(critic): add deterministic peer review gate
1 parent e52e6aa commit 7cab418

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

maf_starter/critic.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
import re
5+
import sys
6+
from typing import Literal
7+
8+
from maf_starter.config import Settings
9+
10+
11+
@dataclass(frozen=True)
12+
class Finding:
13+
check: str
14+
severity: Literal["blocking", "advisory"]
15+
file: str
16+
line: int | None
17+
detail: str
18+
19+
20+
@dataclass(frozen=True)
21+
class DiffFile:
22+
path: str
23+
added_lines: list[str]
24+
25+
26+
@dataclass(frozen=True)
27+
class CriticReport:
28+
findings: list[Finding]
29+
blocking_count: int
30+
advisory_count: int
31+
exit_code: int
32+
33+
34+
_BARE_EXCEPT_PATTERN = re.compile(r"^\s*except\s*:\s*$")
35+
_EXCEPTION_EXCEPT_PATTERN = re.compile(r"^\s*except\s+Exception(?:\s+as\s+\w+)?\s*:\s*$")
36+
_LOG_TOKENS = ("logger.", "logging.", "print(", "emit_failure_telemetry(", "telemetry.")
37+
_TYPED_FAILURE_TOKENS = ("FailureState", "McpException", "ValueError", "RuntimeError", "InvalidOperationException")
38+
39+
40+
def parse_unified_diff(diff_text: str) -> list[DiffFile]:
41+
files: list[DiffFile] = []
42+
current_path: str | None = None
43+
current_added: list[str] = []
44+
45+
for line in diff_text.splitlines():
46+
if line.startswith("diff --git "):
47+
if current_path is not None:
48+
files.append(DiffFile(path=current_path, added_lines=current_added))
49+
current_path = None
50+
current_added = []
51+
continue
52+
if line.startswith("+++ b/"):
53+
current_path = line[6:]
54+
continue
55+
if current_path is None:
56+
continue
57+
if line.startswith("+") and not line.startswith("+++"):
58+
current_added.append(line[1:])
59+
60+
if current_path is not None:
61+
files.append(DiffFile(path=current_path, added_lines=current_added))
62+
63+
return files
64+
65+
66+
def check_bare_except(file_path: str, added_lines: list[str]) -> list[Finding]:
67+
findings: list[Finding] = []
68+
for index, line in enumerate(added_lines):
69+
if not (_BARE_EXCEPT_PATTERN.match(line) or _EXCEPTION_EXCEPT_PATTERN.match(line)):
70+
continue
71+
window = added_lines[index + 1 : index + 5]
72+
has_reraise = any("raise" in candidate for candidate in window)
73+
has_logging = any(any(token in candidate for token in _LOG_TOKENS) for candidate in window)
74+
has_typed_failure = any(any(token in candidate for token in _TYPED_FAILURE_TOKENS) for candidate in window)
75+
if has_reraise and has_logging and has_typed_failure:
76+
continue
77+
findings.append(
78+
Finding(
79+
check="bare-except",
80+
severity="blocking",
81+
file=file_path,
82+
line=index + 1,
83+
detail="Broad exception handler added without a typed, logged re-raise path.",
84+
)
85+
)
86+
return findings
87+
88+
89+
def check_missing_telemetry(file_path: str, added_lines: list[str]) -> list[Finding]:
90+
findings: list[Finding] = []
91+
for index, line in enumerate(added_lines):
92+
if "except" not in line:
93+
continue
94+
window = added_lines[index + 1 : index + 5]
95+
has_logging = any(any(token in candidate for token in _LOG_TOKENS) for candidate in window)
96+
if has_logging:
97+
continue
98+
findings.append(
99+
Finding(
100+
check="missing-telemetry",
101+
severity="advisory",
102+
file=file_path,
103+
line=index + 1,
104+
detail="Exception path does not emit structured telemetry or logging.",
105+
)
106+
)
107+
return findings
108+
109+
110+
def check_file_size_limit(file_path: str, added_line_count: int, limit: int = 400) -> list[Finding]:
111+
if added_line_count <= limit:
112+
return []
113+
return [
114+
Finding(
115+
check="file-size-limit",
116+
severity="advisory",
117+
file=file_path,
118+
line=None,
119+
detail=f"Added line count {added_line_count} exceeds limit {limit}.",
120+
)
121+
]
122+
123+
124+
def run_critic(diff_text: str, *, llm_pass: bool = False, settings: Settings | None = None) -> CriticReport:
125+
if llm_pass:
126+
raise NotImplementedError("LLM critic pass is reserved for a future phase.")
127+
128+
try:
129+
_ = settings
130+
files = parse_unified_diff(diff_text)
131+
if not files:
132+
raise ValueError("No unified diff content was found.")
133+
134+
findings: list[Finding] = []
135+
for diff_file in files:
136+
findings.extend(check_bare_except(diff_file.path, diff_file.added_lines))
137+
findings.extend(check_missing_telemetry(diff_file.path, diff_file.added_lines))
138+
findings.extend(check_file_size_limit(diff_file.path, len(diff_file.added_lines)))
139+
140+
blocking_count = sum(1 for finding in findings if finding.severity == "blocking")
141+
advisory_count = sum(1 for finding in findings if finding.severity == "advisory")
142+
return CriticReport(
143+
findings=findings,
144+
blocking_count=blocking_count,
145+
advisory_count=advisory_count,
146+
exit_code=1 if blocking_count > 0 else 0,
147+
)
148+
except Exception as exc: # noqa: BLE001
149+
print(f"critic error: {exc}", file=sys.stderr)
150+
finding = Finding(
151+
check="critic-error",
152+
severity="advisory",
153+
file="<critic>",
154+
line=None,
155+
detail=str(exc),
156+
)
157+
return CriticReport(findings=[finding], blocking_count=0, advisory_count=1, exit_code=0)

maf_starter/critic_cli.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from pathlib import Path
5+
import sys
6+
7+
from maf_starter.critic import run_critic
8+
9+
10+
def build_parser() -> argparse.ArgumentParser:
11+
parser = argparse.ArgumentParser(prog="critic", description="Run the Resilience First peer critic against a unified diff")
12+
parser.add_argument("--diff", required=True, help="Diff file path or '-' to read from stdin")
13+
parser.add_argument(
14+
"--severity-gate",
15+
choices=("blocking", "advisory"),
16+
default="blocking",
17+
help="Exit non-zero on blocking findings only, or on any finding in advisory mode",
18+
)
19+
return parser
20+
21+
22+
def main(argv: list[str] | None = None) -> int:
23+
parser = build_parser()
24+
args = parser.parse_args(argv)
25+
26+
try:
27+
if args.diff == "-":
28+
diff_text = sys.stdin.read()
29+
else:
30+
diff_text = Path(args.diff).read_text(encoding="utf-8", errors="replace")
31+
except (FileNotFoundError, OSError) as exc:
32+
print(f"critic: unable to read diff: {exc}", file=sys.stderr)
33+
return 2
34+
35+
report = run_critic(diff_text)
36+
for finding in report.findings:
37+
print(f"{finding.file}:{finding.line or '-'} [{finding.severity}] {finding.check} - {finding.detail}")
38+
print(f"critic: {report.blocking_count} blocking, {report.advisory_count} advisory")
39+
40+
if args.severity_gate == "advisory":
41+
return 1 if (report.blocking_count + report.advisory_count) > 0 else 0
42+
return report.exit_code
43+
44+
45+
if __name__ == "__main__":
46+
sys.exit(main())

tests/test_critic.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from __future__ import annotations
2+
3+
import io
4+
from pathlib import Path
5+
import textwrap
6+
7+
import pytest
8+
9+
from maf_starter.critic import (
10+
check_bare_except,
11+
check_file_size_limit,
12+
check_missing_telemetry,
13+
parse_unified_diff,
14+
run_critic,
15+
)
16+
from maf_starter.critic_cli import build_parser, main
17+
18+
19+
DIRTY_DIFF = textwrap.dedent(
20+
"""\
21+
diff --git a/app.py b/app.py
22+
+++ b/app.py
23+
@@
24+
+try:
25+
+ do_work()
26+
+except Exception:
27+
+ pass
28+
"""
29+
)
30+
31+
DIRTY_DIFF_WITH_TELEMETRY_GAP = textwrap.dedent(
32+
"""\
33+
diff --git a/app.py b/app.py
34+
+++ b/app.py
35+
@@
36+
+try:
37+
+ do_work()
38+
+except ValueError:
39+
+ recover()
40+
"""
41+
)
42+
43+
CLEAN_DIFF = textwrap.dedent(
44+
"""\
45+
diff --git a/app.py b/app.py
46+
+++ b/app.py
47+
@@
48+
+try:
49+
+ do_work()
50+
+except Exception as exc:
51+
+ logger.error("failed", exc_info=exc)
52+
+ raise RuntimeError("typed failure") from exc
53+
"""
54+
)
55+
56+
57+
def test_parse_unified_diff_returns_changed_files_and_added_lines() -> None:
58+
diff_text = textwrap.dedent(
59+
"""\
60+
diff --git a/app.py b/app.py
61+
+++ b/app.py
62+
@@
63+
+first
64+
diff --git a/worker.py b/worker.py
65+
+++ b/worker.py
66+
@@
67+
+second
68+
"""
69+
)
70+
71+
files = parse_unified_diff(diff_text)
72+
73+
assert len(files) == 2
74+
assert files[0].path == "app.py"
75+
assert files[0].added_lines == ["first"]
76+
assert files[1].path == "worker.py"
77+
assert files[1].added_lines == ["second"]
78+
79+
80+
def test_check_bare_except_blocks_untyped_unlogged_handler() -> None:
81+
findings = check_bare_except("app.py", ["except Exception:", " pass"])
82+
83+
assert len(findings) == 1
84+
assert findings[0].check == "bare-except"
85+
assert findings[0].severity == "blocking"
86+
87+
88+
def test_check_bare_except_allows_logged_typed_reraise() -> None:
89+
findings = check_bare_except(
90+
"app.py",
91+
[
92+
"except Exception as exc:",
93+
' logger.error("boom", exc_info=exc)',
94+
' raise RuntimeError("typed failure") from exc',
95+
],
96+
)
97+
98+
assert findings == []
99+
100+
101+
def test_check_missing_telemetry_returns_advisory_only() -> None:
102+
findings = check_missing_telemetry("app.py", ["except ValueError:", " recover()"])
103+
104+
assert len(findings) == 1
105+
assert findings[0].check == "missing-telemetry"
106+
assert findings[0].severity == "advisory"
107+
108+
109+
def test_check_file_size_limit_flags_large_files() -> None:
110+
findings = check_file_size_limit("big.py", 401, limit=400)
111+
112+
assert len(findings) == 1
113+
assert findings[0].check == "file-size-limit"
114+
assert findings[0].severity == "advisory"
115+
116+
117+
def test_run_critic_blocks_dirty_diff() -> None:
118+
report = run_critic(DIRTY_DIFF)
119+
120+
assert report.exit_code == 1
121+
assert report.blocking_count == 1
122+
assert any(finding.check == "bare-except" for finding in report.findings)
123+
124+
125+
def test_run_critic_passes_clean_diff() -> None:
126+
report = run_critic(CLEAN_DIFF)
127+
128+
assert report.exit_code == 0
129+
assert report.blocking_count == 0
130+
assert report.advisory_count == 0
131+
132+
133+
def test_run_critic_returns_advisory_error_on_malformed_input() -> None:
134+
report = run_critic("not a diff at all")
135+
136+
assert report.exit_code == 0
137+
assert report.blocking_count == 0
138+
assert report.advisory_count == 1
139+
assert report.findings[0].check == "critic-error"
140+
141+
142+
def test_build_parser_defaults_to_blocking_gate() -> None:
143+
args = build_parser().parse_args(["--diff", "-"])
144+
145+
assert args.diff == "-"
146+
assert args.severity_gate == "blocking"
147+
148+
149+
def test_main_returns_zero_for_clean_diff_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
150+
diff_path = tmp_path / "clean.diff"
151+
diff_path.write_text(CLEAN_DIFF, encoding="utf-8")
152+
153+
exit_code = main(["--diff", str(diff_path)])
154+
155+
out = capsys.readouterr().out
156+
assert exit_code == 0
157+
assert "critic: 0 blocking, 0 advisory" in out
158+
159+
160+
def test_main_returns_one_for_blocking_diff_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
161+
diff_path = tmp_path / "dirty.diff"
162+
diff_path.write_text(DIRTY_DIFF, encoding="utf-8")
163+
164+
exit_code = main(["--diff", str(diff_path)])
165+
166+
out = capsys.readouterr().out
167+
assert exit_code == 1
168+
assert "[blocking] bare-except" in out
169+
170+
171+
def test_main_returns_two_for_missing_diff_path(capsys: pytest.CaptureFixture[str]) -> None:
172+
exit_code = main(["--diff", "missing.diff"])
173+
174+
err = capsys.readouterr().err
175+
assert exit_code == 2
176+
assert "unable to read diff" in err
177+
178+
179+
def test_main_reads_diff_from_stdin_and_supports_advisory_gate(
180+
monkeypatch: pytest.MonkeyPatch,
181+
capsys: pytest.CaptureFixture[str],
182+
) -> None:
183+
monkeypatch.setattr("sys.stdin", io.StringIO(DIRTY_DIFF_WITH_TELEMETRY_GAP))
184+
185+
exit_code = main(["--diff", "-", "--severity-gate", "advisory"])
186+
187+
out = capsys.readouterr().out
188+
assert exit_code == 1
189+
assert "critic: 0 blocking, 1 advisory" in out

0 commit comments

Comments
 (0)