#!/usr/bin/env python3 import argparse import dataclasses import datetime as dt import hashlib import json import pathlib import re import subprocess import sys from typing import Any, Dict, List, Optional, Tuple ROOT = pathlib.Path(__file__).resolve().parents[1] BASELINE_PATH = ROOT / "docs" / "voice-recording-parity-baseline.json" CONSTANT_SPECS = [ { "id": "hold_threshold", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let holdThreshold: TimeInterval = ([0-9.]+)", "expected": "0.19", "telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/ChatTextInputMediaRecordingButton/Sources/ChatTextInputMediaRecordingButton.swift", "telegram_pattern": r"timeout: 0\.19", }, { "id": "cancel_distance_threshold", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let cancelDistanceThreshold: CGFloat = (-?[0-9.]+)", "expected": "-150", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"distanceX < -150\.0f", }, { "id": "cancel_haptic_threshold", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let cancelHapticThreshold: CGFloat = (-?[0-9.]+)", "expected": "-100", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"distanceX < -100\.0", }, { "id": "lock_distance_threshold", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let lockDistanceThreshold: CGFloat = (-?[0-9.]+)", "expected": "-110", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"distanceY < -110\.0f", }, { "id": "lock_haptic_threshold", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let lockHapticThreshold: CGFloat = (-?[0-9.]+)", "expected": "-60", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"distanceY < -60\.0", }, { "id": "velocity_gate", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let velocityGate: CGFloat = (-?[0-9.]+)", "expected": "-400", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"velocity\.x < -400\.0f|velocity\.y < -400\.0f", }, { "id": "lockness_divisor", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let locknessDivisor: CGFloat = ([0-9.]+)", "expected": "105", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"fabs\(_targetTranslation\) / 105\.0f", }, { "id": "drag_normalize_divisor", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let dragNormalizeDivisor: CGFloat = ([0-9.]+)", "expected": "300", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"\(-distanceX\) / 300\.0f|\(-distanceY\) / 300\.0f", }, { "id": "cancel_transform_threshold", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let cancelTransformThreshold: CGFloat = ([0-9.]+)", "expected": "8", "telegram_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", "telegram_pattern": r"VoiceRecordingParityMath\.shouldApplyCancelTransform\(translation\)", }, { "id": "send_accessibility_hit_size", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"static let sendAccessibilityHitSize: CGFloat = ([0-9.]+)", "expected": "120", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"VoiceOver\.Recording\.StopAndPreview", }, { "id": "min_trim_formula", "severity": "P0", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingParityMath.swift", "pattern": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)", "expected": r"max\(1\.0, 56\.0 \* duration / max\(waveformWidth, 1\)\)", "telegram_file": "Telegram-iOS/submodules/TelegramUI/Components/Chat/ChatRecordingPreviewInputPanelNode/Sources/ChatRecordingPreviewInputPanelNode.swift", "telegram_pattern": r"max\(1\.0, 56\.0 \* audio\.duration / waveformBackgroundFrame\.size\.width\)", "raw_match": True, }, ] FLOW_SPEC = { "severity": "P0", "state_file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingFlowTypes.swift", "expected_states": [ "idle", "armed", "recordingUnlocked", "recordingLocked", "waitingForPreview", "draftPreview", ], "required_transitions": [ { "id": "armed", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "setRecordingFlowState(.armed)", }, { "id": "recordingUnlocked", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "setRecordingFlowState(.recordingUnlocked)", }, { "id": "recordingLocked", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "setRecordingFlowState(.recordingLocked)", }, { "id": "waitingForPreview", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "setRecordingFlowState(.waitingForPreview)", }, { "id": "draftPreview", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "setRecordingFlowState(.draftPreview)", }, ], } ACCESSIBILITY_SPECS = [ { "id": "mic_button", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "micButton.accessibilityLabel = \"Voice message\"", }, { "id": "stop_area", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/ComposerView.swift", "snippet": "button.accessibilityIdentifier = \"voice.recording.stopArea\"", }, { "id": "lock_stop", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "snippet": "stopButton.accessibilityLabel = \"Stop recording\"", }, { "id": "slide_to_cancel", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", "snippet": "cancelContainer.accessibilityLabel = \"Slide left to cancel recording\"", }, { "id": "preview_send", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "sendButton.accessibilityLabel = \"Send recording\"", }, { "id": "preview_record_more", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "recordMoreButton.accessibilityLabel = \"Record more\"", }, { "id": "preview_delete", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "deleteButton.accessibilityIdentifier = \"voice.preview.delete\"", }, { "id": "preview_play_pause", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "playButton.accessibilityIdentifier = \"voice.preview.playPause\"", }, { "id": "preview_waveform", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "waveformContainer.accessibilityIdentifier = \"voice.preview.waveform\"", }, ] GEOMETRY_SPECS = [ { "id": "overlay_inner_diameter", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", "pattern": r"private let innerDiameter: CGFloat = ([0-9.]+)", "expected": "110", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"innerCircleRadius = 110\.0f", }, { "id": "overlay_outer_diameter", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", "pattern": r"private let outerDiameter: CGFloat = ([0-9.]+)", "expected": "160", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"outerCircleRadius = innerCircleRadius \+ 50\.0f", }, { "id": "panel_dot_x", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", "pattern": r"private let dotX: CGFloat = ([0-9.]+)", "expected": "5", }, { "id": "panel_timer_x", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", "pattern": r"private let timerX: CGFloat = ([0-9.]+)", "expected": "40", }, { "id": "panel_dot_size", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingPanel.swift", "pattern": r"private let dotSize: CGFloat = ([0-9.]+)", "expected": "10", }, { "id": "lock_panel_width", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "pattern": r"private let panelWidth: CGFloat = ([0-9.]+)", "expected": "40", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)", }, { "id": "lock_panel_full_height", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "pattern": r"private let panelFullHeight: CGFloat = ([0-9.]+)", "expected": "72", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"CGRectMake\(0\.0f, 0\.0f, 40\.0f, 72\.0f\)", }, { "id": "lock_panel_locked_height", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "pattern": r"private let panelLockedHeight: CGFloat = ([0-9.]+)", "expected": "40", }, { "id": "lock_vertical_offset", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "pattern": r"private let verticalOffset: CGFloat = ([0-9.]+)", "expected": "122", "telegram_file": "Telegram-iOS/submodules/LegacyComponents/Sources/TGModernConversationInputMicButton.m", "telegram_pattern": r"centerPoint\.y - 122\.0f", }, ] ANIMATION_SPECS = [ { "id": "preview_play_to_pause_frames", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "playPauseAnimationView.play(fromFrame: 0, toFrame: 41, loopMode: .playOnce)", }, { "id": "preview_pause_to_play_frames", "severity": "P1", "file": "Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.swift", "snippet": "playPauseAnimationView.play(fromFrame: 41, toFrame: 83, loopMode: .playOnce)", }, { "id": "overlay_spring_timing", "severity": "P2", "file": "Rosetta/Features/Chats/ChatDetail/VoiceRecordingOverlay.swift", "snippet": "UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.55", }, { "id": "lock_stop_delay", "severity": "P2", "file": "Rosetta/Features/Chats/ChatDetail/RecordingLockView.swift", "snippet": "UIView.animate(withDuration: 0.25, delay: 0.56, options: [.curveEaseOut])", }, ] @dataclasses.dataclass class Finding: severity: str kind: str layer: str item_id: str expected: str actual: str delta: str evidence: str def to_dict(self) -> Dict[str, Any]: return { "severity": self.severity, "kind": self.kind, "layer": self.layer, "id": self.item_id, "expected": self.expected, "actual": self.actual, "delta": self.delta, "evidence": self.evidence, } def read_text(path: pathlib.Path) -> str: return path.read_text(encoding="utf-8") def sha256_file(path: pathlib.Path) -> str: h = hashlib.sha256() with path.open("rb") as f: while True: chunk = f.read(1024 * 64) if not chunk: break h.update(chunk) return h.hexdigest() def parse_image_size(path: pathlib.Path) -> Optional[Tuple[int, int]]: if path.suffix.lower() not in {".png", ".jpg", ".jpeg"}: return None try: proc = subprocess.run( ["/usr/bin/sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)], check=False, capture_output=True, text=True, ) except FileNotFoundError: return None if proc.returncode != 0: return None width = None height = None for line in proc.stdout.splitlines(): if "pixelWidth:" in line: width = int(line.split(":", 1)[1].strip()) if "pixelHeight:" in line: height = int(line.split(":", 1)[1].strip()) if width is None or height is None: return None return (width, height) def parse_lottie_meta(path: pathlib.Path) -> Dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) return { "fps": data.get("fr"), "ip": data.get("ip"), "op": data.get("op"), "width": data.get("w"), "height": data.get("h"), } def discover_asset_manifest() -> Dict[str, Any]: imagesets: List[Dict[str, Any]] = [] assets_root = ROOT / "Rosetta" / "Assets.xcassets" for imageset in sorted(assets_root.glob("VoiceRecording*.imageset")): files = [] for file_path in sorted(imageset.iterdir()): if file_path.name == "Contents.json": continue file_info: Dict[str, Any] = { "name": file_path.name, "sha256": sha256_file(file_path), } size = parse_image_size(file_path) if size is not None: file_info["size"] = {"width": size[0], "height": size[1]} files.append(file_info) imagesets.append( { "id": imageset.name.replace(".imageset", ""), "severity": "P1", "path": str(imageset.relative_to(ROOT)), "files": files, } ) lottie: List[Dict[str, Any]] = [] lottie_root = ROOT / "Rosetta" / "Resources" / "Lottie" for lottie_file in sorted(lottie_root.glob("voice_*.json")): lottie.append( { "id": lottie_file.stem, "severity": "P1", "path": str(lottie_file.relative_to(ROOT)), "sha256": sha256_file(lottie_file), "meta": parse_lottie_meta(lottie_file), } ) return {"imagesets": imagesets, "lottie": lottie} def parse_flow_states(path: pathlib.Path) -> List[str]: text = read_text(path) return re.findall(r"case\s+([A-Za-z_][A-Za-z0-9_]*)", text) def build_baseline() -> Dict[str, Any]: return { "version": 1, "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), "constants": CONSTANT_SPECS, "flow": { **FLOW_SPEC, "actual_states": parse_flow_states(ROOT / FLOW_SPEC["state_file"]), }, "accessibility": ACCESSIBILITY_SPECS, "geometry": GEOMETRY_SPECS, "animations": ANIMATION_SPECS, "assets": discover_asset_manifest(), } def parse_actual_constant(text: str, pattern: str, raw_match: bool) -> Optional[str]: if raw_match: return pattern if re.search(pattern, text) else None m = re.search(pattern, text) if not m: return None return m.group(1) def run_checker(baseline: Dict[str, Any]) -> Dict[str, Any]: findings: List[Finding] = [] # constants for spec in baseline.get("constants", []): file_path = ROOT / spec["file"] text = read_text(file_path) raw_match = bool(spec.get("raw_match", False)) actual = parse_actual_constant(text, spec["pattern"], raw_match) expected = spec["expected"] if actual is None: findings.append( Finding( severity=spec["severity"], kind="missing_pattern", layer="constants", item_id=spec["id"], expected=str(expected), actual="missing", delta="pattern_not_found", evidence=spec["file"], ) ) elif str(actual) != str(expected): findings.append( Finding( severity=spec["severity"], kind="value_mismatch", layer="constants", item_id=spec["id"], expected=str(expected), actual=str(actual), delta=f"{actual} != {expected}", evidence=spec["file"], ) ) telegram_file_raw = spec.get("telegram_file") telegram_pattern = spec.get("telegram_pattern") if telegram_file_raw and telegram_pattern: telegram_file = ROOT / telegram_file_raw telegram_text = read_text(telegram_file) if re.search(telegram_pattern, telegram_text): continue findings.append( Finding( severity=spec["severity"], kind="telegram_reference_missing", layer="constants", item_id=spec["id"], expected=telegram_pattern, actual="missing", delta="telegram_evidence_not_found", evidence=telegram_file_raw, ) ) # geometry for spec in baseline.get("geometry", []): file_path = ROOT / spec["file"] text = read_text(file_path) actual = parse_actual_constant(text, spec["pattern"], bool(spec.get("raw_match", False))) expected = spec["expected"] if actual is None: findings.append( Finding( severity=spec["severity"], kind="missing_pattern", layer="geometry", item_id=spec["id"], expected=str(expected), actual="missing", delta="pattern_not_found", evidence=spec["file"], ) ) elif str(actual) != str(expected): findings.append( Finding( severity=spec["severity"], kind="value_mismatch", layer="geometry", item_id=spec["id"], expected=str(expected), actual=str(actual), delta=f"{actual} != {expected}", evidence=spec["file"], ) ) telegram_file_raw = spec.get("telegram_file") telegram_pattern = spec.get("telegram_pattern") if telegram_file_raw and telegram_pattern: telegram_file = ROOT / telegram_file_raw telegram_text = read_text(telegram_file) if re.search(telegram_pattern, telegram_text): continue findings.append( Finding( severity=spec["severity"], kind="telegram_reference_missing", layer="geometry", item_id=spec["id"], expected=telegram_pattern, actual="missing", delta="telegram_evidence_not_found", evidence=telegram_file_raw, ) ) # flow flow = baseline["flow"] state_file = ROOT / flow["state_file"] actual_states = parse_flow_states(state_file) expected_states = flow["expected_states"] if actual_states != expected_states: findings.append( Finding( severity=flow["severity"], kind="state_machine_mismatch", layer="flow", item_id="flow_states", expected=",".join(expected_states), actual=",".join(actual_states), delta="state_list_diff", evidence=flow["state_file"], ) ) for transition in flow.get("required_transitions", []): transition_file = ROOT / transition["file"] transition_text = read_text(transition_file) if transition["snippet"] not in transition_text: findings.append( Finding( severity=transition["severity"], kind="transition_missing", layer="flow", item_id=transition["id"], expected=transition["snippet"], actual="missing", delta="snippet_not_found", evidence=transition["file"], ) ) # accessibility for spec in baseline.get("accessibility", []): file_path = ROOT / spec["file"] text = read_text(file_path) if spec["snippet"] not in text: findings.append( Finding( severity=spec["severity"], kind="accessibility_missing", layer="accessibility", item_id=spec["id"], expected=spec["snippet"], actual="missing", delta="snippet_not_found", evidence=spec["file"], ) ) # animation snippets for spec in baseline.get("animations", []): file_path = ROOT / spec["file"] text = read_text(file_path) if spec["snippet"] not in text: findings.append( Finding( severity=spec["severity"], kind="animation_snippet_missing", layer="animations", item_id=spec["id"], expected=spec["snippet"], actual="missing", delta="snippet_not_found", evidence=spec["file"], ) ) # assets/imagesets assets = baseline.get("assets", {}) for spec in assets.get("imagesets", []): imageset_path = ROOT / spec["path"] if not imageset_path.exists(): findings.append( Finding( severity=spec["severity"], kind="asset_missing", layer="assets", item_id=spec["id"], expected=spec["path"], actual="missing", delta="imageset_missing", evidence=spec["path"], ) ) continue for file_spec in spec.get("files", []): file_path = imageset_path / file_spec["name"] if not file_path.exists(): findings.append( Finding( severity=spec["severity"], kind="asset_file_missing", layer="assets", item_id=f"{spec['id']}/{file_spec['name']}", expected=file_spec["name"], actual="missing", delta="file_missing", evidence=str(imageset_path.relative_to(ROOT)), ) ) continue actual_sha = sha256_file(file_path) if actual_sha != file_spec["sha256"]: findings.append( Finding( severity=spec["severity"], kind="asset_hash_mismatch", layer="assets", item_id=f"{spec['id']}/{file_spec['name']}", expected=file_spec["sha256"], actual=actual_sha, delta="sha256_mismatch", evidence=str(file_path.relative_to(ROOT)), ) ) expected_size = file_spec.get("size") if expected_size is not None: actual_size = parse_image_size(file_path) if actual_size is None: findings.append( Finding( severity=spec["severity"], kind="asset_size_missing", layer="assets", item_id=f"{spec['id']}/{file_spec['name']}", expected=f"{expected_size['width']}x{expected_size['height']}", actual="unknown", delta="size_unavailable", evidence=str(file_path.relative_to(ROOT)), ) ) else: if actual_size != (expected_size["width"], expected_size["height"]): findings.append( Finding( severity=spec["severity"], kind="asset_size_mismatch", layer="assets", item_id=f"{spec['id']}/{file_spec['name']}", expected=f"{expected_size['width']}x{expected_size['height']}", actual=f"{actual_size[0]}x{actual_size[1]}", delta="size_mismatch", evidence=str(file_path.relative_to(ROOT)), ) ) # assets/lottie for spec in assets.get("lottie", []): lottie_path = ROOT / spec["path"] if not lottie_path.exists(): findings.append( Finding( severity=spec["severity"], kind="lottie_missing", layer="assets", item_id=spec["id"], expected=spec["path"], actual="missing", delta="lottie_missing", evidence=spec["path"], ) ) continue actual_sha = sha256_file(lottie_path) if actual_sha != spec["sha256"]: findings.append( Finding( severity=spec["severity"], kind="lottie_hash_mismatch", layer="assets", item_id=spec["id"], expected=spec["sha256"], actual=actual_sha, delta="sha256_mismatch", evidence=spec["path"], ) ) actual_meta = parse_lottie_meta(lottie_path) expected_meta = spec.get("meta", {}) for key in ["fps", "ip", "op", "width", "height"]: if expected_meta.get(key) != actual_meta.get(key): findings.append( Finding( severity=spec["severity"], kind="lottie_meta_mismatch", layer="assets", item_id=f"{spec['id']}.{key}", expected=str(expected_meta.get(key)), actual=str(actual_meta.get(key)), delta=f"{key}_mismatch", evidence=spec["path"], ) ) finding_dicts = [f.to_dict() for f in findings] counts = {"P0": 0, "P1": 0, "P2": 0, "P3": 0} for f in findings: counts[f.severity] = counts.get(f.severity, 0) + 1 return { "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(), "summary": { "total": len(findings), "by_severity": counts, }, "findings": finding_dicts, } def severity_rank(severity: str) -> int: order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} return order.get(severity, 99) def main() -> int: parser = argparse.ArgumentParser(description="Voice recording parity checker") parser.add_argument("--baseline", default=str(BASELINE_PATH)) parser.add_argument("--emit-baseline", action="store_true") parser.add_argument("--report-json", action="store_true") parser.add_argument("--pretty", action="store_true") parser.add_argument("--fail-on", default="P1", choices=["P0", "P1", "P2", "P3", "none"]) args = parser.parse_args() baseline_path = pathlib.Path(args.baseline) if args.emit_baseline: baseline = build_baseline() baseline_path.write_text(json.dumps(baseline, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.report_json: print(json.dumps({"baseline_written": str(baseline_path.relative_to(ROOT))}, ensure_ascii=False)) else: print(f"Baseline written: {baseline_path}") return 0 if not baseline_path.exists(): print(f"Baseline not found: {baseline_path}", file=sys.stderr) return 2 baseline = json.loads(baseline_path.read_text(encoding="utf-8")) report = run_checker(baseline) if args.report_json or args.pretty: print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None)) else: total = report["summary"]["total"] counts = report["summary"]["by_severity"] print(f"findings={total} P0={counts.get('P0', 0)} P1={counts.get('P1', 0)} P2={counts.get('P2', 0)} P3={counts.get('P3', 0)}") if args.fail_on != "none": threshold = severity_rank(args.fail_on) for finding in report["findings"]: if severity_rank(finding["severity"]) <= threshold: return 1 return 0 if __name__ == "__main__": raise SystemExit(main())