1010from __future__ import annotations
1111
1212import argparse
13+ import sys
1314import time
1415from pathlib import Path
1516from typing import Sequence
1819from agent .filtering import evaluate_filter_decision
1920from agent .report import build_report
2021from agent .report import write_reports
22+ from agent .rules import RULES_MANIFEST
2123from agent .rules import run_static_rules
2224from agent .sandbox import DryRunSandboxRunner
2325from agent .storage import persist_review
2426from agent .telemetry import build_telemetry_summary
2527
2628
29+ _SEVERITY_RANK = {
30+ "low" : 1 ,
31+ "medium" : 2 ,
32+ "high" : 3 ,
33+ }
34+
35+
2736def _resolve_path (raw : str ) -> Path :
2837 path = Path (raw ).expanduser ()
2938 if path .is_absolute ():
@@ -35,12 +44,39 @@ def _default_diff_path() -> str:
3544 return str (Path (__file__ ).resolve ().parent / "fixtures" / "security.diff" )
3645
3746
47+ def _read_diff (raw : str ) -> tuple [str , str , str ]:
48+ if raw == "-" :
49+ return "<stdin>" , "<stdin>" , sys .stdin .read ()
50+
51+ diff_file = _resolve_path (raw )
52+ if not diff_file .exists ():
53+ raise FileNotFoundError (f"diff file not found: { diff_file } " )
54+ return str (diff_file ), raw , diff_file .read_text (encoding = "utf-8" )
55+
56+
57+ def _print_rules () -> None :
58+ print ("Deterministic rules" )
59+ for rule in RULES_MANIFEST :
60+ print (f"- { rule ['id' ]} " )
61+ print (f" category: { rule ['category' ]} " )
62+ print (f" default severity: { rule ['default_severity' ]} " )
63+ print (f" description: { rule ['description' ]} " )
64+ print (f" limitations: { rule ['limitations' ]} " )
65+
66+
67+ def _failure_gate_triggered (findings : Sequence [object ], threshold : str ) -> bool :
68+ if threshold == "never" :
69+ return False
70+ minimum = _SEVERITY_RANK [threshold ]
71+ return any (_SEVERITY_RANK .get (getattr (finding , "severity" , "" ), 0 ) >= minimum for finding in findings )
72+
73+
3874def parse_args (argv : Sequence [str ] | None = None ) -> argparse .Namespace :
3975 parser = argparse .ArgumentParser (description = "Run deterministic code review over a unified diff." )
4076 parser .add_argument (
4177 "--diff-file" ,
4278 default = _default_diff_path (),
43- help = "Path to a unified diff file. Defaults to fixtures/security.diff." ,
79+ help = "Path to a unified diff file, or '-' to read from stdin . Defaults to fixtures/security.diff." ,
4480 )
4581 parser .add_argument (
4682 "--output-dir" ,
@@ -57,23 +93,34 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
5793 default = "" ,
5894 help = "Optional SQLite database path for persisting review tasks, findings, and reports." ,
5995 )
96+ parser .add_argument (
97+ "--fail-on-severity" ,
98+ choices = ("never" , "low" , "medium" , "high" ),
99+ default = "never" ,
100+ help = "Exit with status 1 when findings meet or exceed this severity. Defaults to never." ,
101+ )
102+ parser .add_argument (
103+ "--list-rules" ,
104+ action = "store_true" ,
105+ help = "Print deterministic rule metadata and exit without reading a diff." ,
106+ )
60107 return parser .parse_args (argv )
61108
62109
63110def main (argv : Sequence [str ] | None = None ) -> int :
64111 started = time .perf_counter ()
65112 args = parse_args (argv )
66- diff_file = _resolve_path (args .diff_file )
113+ if args .list_rules :
114+ _print_rules ()
115+ return 0
116+
67117 output_dir = _resolve_path (args .output_dir )
68118 db_path = _resolve_path (args .db_path ) if args .db_path else None
69-
70- if not diff_file .exists ():
71- raise FileNotFoundError (f"diff file not found: { diff_file } " )
72-
73- diff_text = diff_file .read_text (encoding = "utf-8" )
119+ diff_display , report_diff_file , diff_text = _read_diff (args .diff_file )
74120 parsed_diff = parse_unified_diff (diff_text )
75121 filter_decision = evaluate_filter_decision (diff_text , parsed_diff )
76122 findings = run_static_rules (parsed_diff .changed_lines )
123+ failure_gate_triggered = _failure_gate_triggered (findings , args .fail_on_severity )
77124 sandbox_run = DryRunSandboxRunner ().run (
78125 files = parsed_diff .files ,
79126 findings = findings ,
@@ -88,7 +135,7 @@ def main(argv: Sequence[str] | None = None) -> int:
88135 duration_ms = duration_ms ,
89136 )
90137 report = build_report (
91- diff_file = args . diff_file ,
138+ diff_file = report_diff_file ,
92139 files = parsed_diff .files ,
93140 findings = findings ,
94141 dry_run = args .dry_run ,
@@ -111,17 +158,23 @@ def main(argv: Sequence[str] | None = None) -> int:
111158 low_count = report ["summary" ]["severity_counts" ].get ("low" , 0 )
112159
113160 print ("Skills code review dry-run complete" )
114- print (f"Diff file: { diff_file } " )
161+ print (f"Diff file: { diff_display } " )
115162 print (f"Changed files: { len (parsed_diff .files )} " )
116163 print (f"Findings: high={ high_count } medium={ medium_count } low={ low_count } " )
117164 print (f"Filter decision: { filter_decision .decision } ({ filter_decision .reason } )" )
118165 print (f"Sandbox status: { sandbox_run .status } " )
166+ if args .fail_on_severity == "never" :
167+ print ("Failure gate: disabled (--fail-on-severity never)" )
168+ elif failure_gate_triggered :
169+ print (f"Failure gate: triggered (--fail-on-severity { args .fail_on_severity } )" )
170+ else :
171+ print (f"Failure gate: passed (--fail-on-severity { args .fail_on_severity } )" )
119172 print (f"JSON report: { json_path } " )
120173 print (f"Markdown report: { md_path } " )
121174 if db_path is not None :
122175 print (f"Database: { db_path } " )
123176 print (f"Task ID: { task_id } " )
124- return 0
177+ return 1 if failure_gate_triggered else 0
125178
126179
127180if __name__ == "__main__" :
0 commit comments