#!/usr/bin/env python3 """Compare sanitized passing and failing CI logs and emit an evidence report. This tool is deliberately local and heuristic. It never uploads logs and does not claim that a textual difference proves root cause. Sanitize inputs before use. """ from __future__ import annotations import argparse import collections import hashlib import json import re import sys from pathlib import Path SECRET_PATTERNS = [ re.compile(r"(?i)(authorization|token|password|passwd|secret|api[_-]?key)\s*[:=]\s*\S+"), re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), re.compile(r"(?i)https?://[^/\s:@]+:[^/\s@]+@"), ] NOISE = [ re.compile(r"^\s*$"), re.compile(r"(?i)^\s*(timestamp|time|date|duration|elapsed)\s*[:=]"), re.compile(r"^##\[(?:debug|command)\]"), ] ERROR = re.compile(r"(?i)\b(error|failed|failure|fatal|exception|traceback|panic|segmentation fault|timed out)\b") WARNING = re.compile(r"(?i)\b(warn(?:ing)?|deprecated|retry|flaky)\b") ENVIRONMENT = re.compile(r"(?i)\b(python|node(?:\.js)?|npm|pnpm|yarn|java|jdk|ruby|go version|rustc|ubuntu|windows|macos|runner|image|container|dependency|package)\b") ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") TIMESTAMP = re.compile(r"(? str: data = path.read_bytes() if len(data) > max_bytes: raise ValueError(f"{path}: {len(data)} bytes exceeds --max-bytes {max_bytes}") if b"\x00" in data: raise ValueError(f"{path}: binary/NUL content is not supported") return data.decode("utf-8", errors="replace") def secret_findings(text: str) -> list[str]: findings = [] for pattern in SECRET_PATTERNS: if pattern.search(text): findings.append(pattern.pattern) return findings def normalize(line: str) -> str: line = ANSI.sub("", line) line = TIMESTAMP.sub("", line) line = DURATION.sub("", line) line = HEX.sub("", line) return SPACE.sub(" ", line).strip() def useful_lines(text: str) -> list[str]: lines = [] for raw in text.splitlines(): line = normalize(raw) if any(pattern.search(line) for pattern in NOISE): continue if line: lines.append(line[:1000]) return lines def unique_delta(passing: list[str], failing: list[str], limit: int) -> list[tuple[str, int]]: passing_counts = collections.Counter(passing) failing_counts = collections.Counter(failing) delta = [] for line, count in failing_counts.items(): extra = count - passing_counts.get(line, 0) if extra > 0: delta.append((line, extra)) delta.sort(key=lambda item: ( 0 if ERROR.search(item[0]) else 1 if WARNING.search(item[0]) else 2, -item[1], item[0].lower(), )) return delta[:limit] def classify(lines: list[tuple[str, int]], pattern: re.Pattern[str], limit: int) -> list[tuple[str, int]]: return [item for item in lines if pattern.search(item[0])][:limit] def quote(line: str) -> str: return line.replace("`", "'") def render_markdown(passing_path: Path, failing_path: Path, passing: list[str], failing: list[str], limit: int) -> str: delta = unique_delta(passing, failing, limit * 4) errors = classify(delta, ERROR, limit) warnings = classify(delta, WARNING, limit) environment = classify(delta, ENVIRONMENT, limit) other = [item for item in delta if item not in errors and item not in warnings and item not in environment][:limit] def section(title: str, items: list[tuple[str, int]]) -> str: rows = [f"## {title}"] if not items: rows.append("No matching failing-only lines were detected.") else: for line, count in items: suffix = f" (appears {count} additional times)" if count > 1 else "" rows.append(f"- `{quote(line)}`{suffix}") return "\n".join(rows) passing_hash = hashlib.sha256("\n".join(passing).encode()).hexdigest() failing_hash = hashlib.sha256("\n".join(failing).encode()).hexdigest() parts = [ "# CI run comparison report", "", "## Scope and limitations", f"- Passing input: `{passing_path.name}` ({len(passing)} normalized non-noise lines)", f"- Failing input: `{failing_path.name}` ({len(failing)} normalized non-noise lines)", f"- Passing normalized SHA-256: `{passing_hash}`", f"- Failing normalized SHA-256: `{failing_hash}`", "- This is a deterministic textual comparison, not proof of root cause.", "- Dynamic timestamps, durations, and revision-like hex strings were normalized.", "- Inputs must be sanitized before use; no repository access or network transfer occurs.", "", section("New error-like evidence", errors), "", section("New warning-like evidence", warnings), "", section("Environment or dependency differences", environment), "", section("Other failing-only lines", other), "", "## Suggested human validation", "1. Confirm both logs represent comparable workflow jobs and inputs.", "2. Check the earliest failing-only error against preceding environment changes.", "3. Reproduce with the smallest targeted command and record its exit status.", "4. Treat each inferred cause as a hypothesis until a controlled rerun validates it.", "", ] return "\n".join(parts) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("passing_log", type=Path) parser.add_argument("failing_log", type=Path) parser.add_argument("--output", type=Path, help="write Markdown here instead of stdout") parser.add_argument("--json-summary", type=Path, help="write machine-readable counts here") parser.add_argument("--limit", type=int, default=20) parser.add_argument("--max-bytes", type=int, default=5_000_000) parser.add_argument("--allow-sensitive-patterns", action="store_true", help="continue despite possible secret markers (not recommended)") args = parser.parse_args() if not 1 <= args.limit <= 200: parser.error("--limit must be between 1 and 200") if not 1 <= args.max_bytes <= 50_000_000: parser.error("--max-bytes must be between 1 and 50000000") try: passing_text = read_log(args.passing_log, args.max_bytes) failing_text = read_log(args.failing_log, args.max_bytes) except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 findings = { "passing_log": secret_findings(passing_text), "failing_log": secret_findings(failing_text), } if any(findings.values()) and not args.allow_sensitive_patterns: print("error: possible sensitive values detected; sanitize inputs before comparison", file=sys.stderr) for name, matches in findings.items(): if matches: print(f" {name}: {len(matches)} sensitive-pattern class(es)", file=sys.stderr) return 3 passing = useful_lines(passing_text) failing = useful_lines(failing_text) report = render_markdown(args.passing_log, args.failing_log, passing, failing, args.limit) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(report, encoding="utf-8") else: print(report, end="") if args.json_summary: delta = unique_delta(passing, failing, args.limit * 4) payload = { "passing_normalized_lines": len(passing), "failing_normalized_lines": len(failing), "failing_only_unique_lines_considered": len(delta), "new_error_like_lines": len(classify(delta, ERROR, args.limit)), "new_warning_like_lines": len(classify(delta, WARNING, args.limit)), "environment_difference_lines": len(classify(delta, ENVIRONMENT, args.limit)), "network_requests": 0, "root_cause_proven": False, } args.json_summary.parent.mkdir(parents=True, exist_ok=True) args.json_summary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main())