replico 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
replico/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Replico — CI failures, reproduced locally.
2
+
3
+ Replico turns a failed GitHub Actions run into a locally reproducible
4
+ failure whenever it can reconstruct the relevant conditions.
5
+ """
6
+
7
+ __version__ = "0.1.0"
replico/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m replico`."""
2
+
3
+ from replico.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Log analysis and failure classification."""
@@ -0,0 +1,304 @@
1
+ """Evidence-based failure classification.
2
+
3
+ Classification is a set of ordered rules over observed evidence. Replico
4
+ never invents an explanation: when nothing matches, the category is UNKNOWN
5
+ with a low confidence and a note saying so.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ from replico.models import FailureEvidence
14
+
15
+ # Failure categories (stable identifiers, documented in README).
16
+ DEPENDENCY_FAILURE = "DEPENDENCY_FAILURE"
17
+ VERSION_FAILURE = "VERSION_FAILURE"
18
+ PYTHON_VERSION_FAILURE = "PYTHON_VERSION_FAILURE"
19
+ NODE_VERSION_FAILURE = "NODE_VERSION_FAILURE"
20
+ OS_DIFFERENCE = "OS_DIFFERENCE"
21
+ ENVIRONMENT_VARIABLE = "ENVIRONMENT_VARIABLE"
22
+ MISSING_TOOL = "MISSING_TOOL"
23
+ MISSING_FILE = "MISSING_FILE"
24
+ TEST_FAILURE = "TEST_FAILURE"
25
+ BUILD_FAILURE = "BUILD_FAILURE"
26
+ NETWORK_FAILURE = "NETWORK_FAILURE"
27
+ TIMEOUT = "TIMEOUT"
28
+ PERMISSION_FAILURE = "PERMISSION_FAILURE"
29
+ WORKFLOW_CONFIGURATION = "WORKFLOW_CONFIGURATION"
30
+ UNKNOWN = "UNKNOWN"
31
+
32
+ CATEGORY_NAMES = {
33
+ DEPENDENCY_FAILURE: "dependency failure",
34
+ VERSION_FAILURE: "version failure",
35
+ PYTHON_VERSION_FAILURE: "Python version failure",
36
+ NODE_VERSION_FAILURE: "Node version failure",
37
+ OS_DIFFERENCE: "OS difference",
38
+ ENVIRONMENT_VARIABLE: "missing/different environment variable",
39
+ MISSING_TOOL: "missing tool/executable",
40
+ MISSING_FILE: "missing file",
41
+ TEST_FAILURE: "test failure",
42
+ BUILD_FAILURE: "build failure",
43
+ NETWORK_FAILURE: "network failure",
44
+ TIMEOUT: "timeout",
45
+ PERMISSION_FAILURE: "permission failure",
46
+ WORKFLOW_CONFIGURATION: "workflow configuration",
47
+ UNKNOWN: "unknown",
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class FailureClassification:
53
+ category: str = UNKNOWN
54
+ confidence: int = 0 # 0..100
55
+ explanation: list[str] = field(default_factory=list)
56
+ kind: str = ""
57
+
58
+
59
+ @dataclass
60
+ class _Rule:
61
+ name: str
62
+ patterns: list[re.Pattern]
63
+ category: str
64
+ confidence: int
65
+ explanation: str
66
+
67
+
68
+ _RULES: list[_Rule] = []
69
+
70
+
71
+ def _rule(name, category, confidence, explanation, *patterns):
72
+ _RULES.append(
73
+ _Rule(
74
+ name=name,
75
+ patterns=[re.compile(p, re.IGNORECASE) for p in patterns],
76
+ category=category,
77
+ confidence=confidence,
78
+ explanation=explanation,
79
+ )
80
+ )
81
+
82
+
83
+ _rule(
84
+ "python_missing_package",
85
+ DEPENDENCY_FAILURE,
86
+ 90,
87
+ "Python reported a missing module, which usually means CI installed a "
88
+ "dependency that is not present (or not installed) locally.",
89
+ r"ModuleNotFoundError:\s*No module named '([^']+)'",
90
+ r"ImportError:\s*No module named",
91
+ r"Could not import",
92
+ )
93
+ _rule(
94
+ "pip_resolution",
95
+ DEPENDENCY_FAILURE,
96
+ 92,
97
+ "The package resolver could not find a satisfying set of versions; CI and "
98
+ "local dependency graphs are likely out of sync.",
99
+ r"ResolutionImpossible",
100
+ r"No matching distribution found",
101
+ r"Could not find a version that satisfies",
102
+ r"pip.*(?:conflict|resolution|incompatible)",
103
+ r"ERROR: Cannot install",
104
+ )
105
+ _rule(
106
+ "pytest_failure",
107
+ TEST_FAILURE,
108
+ 94,
109
+ "A pytest test failed in CI. Compare the failing test id with the local run "
110
+ "to confirm the same test fails locally.",
111
+ r"FAILED\s+\S+::\S+",
112
+ r"short test summary info",
113
+ )
114
+ _rule(
115
+ "assertion",
116
+ TEST_FAILURE,
117
+ 85,
118
+ "An assertion failed during execution — behavior differs between CI and the local environment.",
119
+ r"AssertionError",
120
+ r"assert\s",
121
+ )
122
+ _rule(
123
+ "python_version_required",
124
+ PYTHON_VERSION_FAILURE,
125
+ 88,
126
+ "The toolchain reported a Python interpreter requirement that is not met.",
127
+ r"requires Python >=? ?\d+\.\d+",
128
+ r"Python \d+\.\d+.*(?:required|needed)",
129
+ r"not supported.*python",
130
+ r"RuntimeError: Python \d+\.\d+",
131
+ )
132
+ _rule(
133
+ "node_version_required",
134
+ NODE_VERSION_FAILURE,
135
+ 88,
136
+ "Node reported an engine/version requirement that is not satisfied.",
137
+ r"Unsupported engine",
138
+ r"requires node >=? ?\d+",
139
+ r"engine \"node\"",
140
+ r"ENOTSUP",
141
+ r"v\d+\.\d+\.\d+.*not supported|not supported.*node",
142
+ )
143
+ _rule(
144
+ "missing_executable",
145
+ MISSING_TOOL,
146
+ 88,
147
+ "A tool invoked by the workflow is not installed in the environment.",
148
+ r"(?:command not found|is not recognized|not recognized as an? internal)",
149
+ r"execvp\(.*No such file",
150
+ r"The process ['\"]?[^'\"]+['\"]? failed with exit code 127",
151
+ r"FileNotFoundError: \[Errno 2\].*No such file or directory: '([^']+)'",
152
+ )
153
+ _rule(
154
+ "missing_file",
155
+ MISSING_FILE,
156
+ 86,
157
+ "A file referenced by the workflow does not exist in the repository or "
158
+ "was not produced before the failing step.",
159
+ r"No such file or directory",
160
+ r"cannot open file ['\"]?([^'\"]+)['\"]?",
161
+ r"error: pathspec ['\"]?[^'\"]+['\"]? did not match",
162
+ )
163
+ _rule(
164
+ "timeout",
165
+ TIMEOUT,
166
+ 92,
167
+ "The run exceeded a time budget (step or job timeout).",
168
+ r"timed out|timed?out",
169
+ r"The operation was canceled",
170
+ r"##\[error\]The operation was canceled",
171
+ r"exit code 124",
172
+ )
173
+ _rule(
174
+ "network",
175
+ NETWORK_FAILURE,
176
+ 86,
177
+ "A network operation failed (download, package registry, DNS).",
178
+ r"Could not resolve host|Temporary failure in name resolution",
179
+ r"Connection (?:refused|reset|timed out)",
180
+ r"Failed to (?:download|fetch|connect)",
181
+ r"ssl: (?:wrong version number|certificate verify failed)",
182
+ r"getaddrinfo failed",
183
+ r"EAI_AGAIN",
184
+ )
185
+ _rule(
186
+ "permission",
187
+ PERMISSION_FAILURE,
188
+ 88,
189
+ "An operation failed because of missing permissions.",
190
+ r"Permission denied",
191
+ r"PermissionError",
192
+ r"EACCES|EPERM",
193
+ r"Access is denied",
194
+ r"denied \(publickey\)",
195
+ )
196
+ _rule(
197
+ "build_compile",
198
+ BUILD_FAILURE,
199
+ 84,
200
+ "Compilation or a language build step failed.",
201
+ r"(^|[\s'\"])error(\[E\d+\])?:[^\n]*",
202
+ r"fatal error:",
203
+ r"error: linking",
204
+ r"Build FAILED",
205
+ r"gcc: error",
206
+ r"cargo build.*failed",
207
+ )
208
+ _rule(
209
+ "env_missing",
210
+ ENVIRONMENT_VARIABLE,
211
+ 85,
212
+ "An environment variable that the code expects was not provided.",
213
+ r"KeyError: ['\"]([A-Z_]+)['\"]",
214
+ r"Environment variable ['\"]?([A-Z_]+)['\"]? (?:is|was) not (?:set|found)",
215
+ r"os\.environ\[[^\]]+\].*KeyError",
216
+ r"Missing environment variable",
217
+ )
218
+ _rule(
219
+ "workflow_config",
220
+ WORKFLOW_CONFIGURATION,
221
+ 80,
222
+ "The workflow itself is invalid or references something that does not exist.",
223
+ r"Workflow does not have|Invalid workflow file",
224
+ r"Unable to resolve action",
225
+ r"Error: .*action.*not found",
226
+ r"Could not find action",
227
+ )
228
+ _rule(
229
+ "segfault_crash",
230
+ BUILD_FAILURE,
231
+ 80,
232
+ "The process crashed (segfault / killed), often due to an environment "
233
+ "difference such as compiler flags or memory limits.",
234
+ r"Segmentation fault",
235
+ r"^Killed$",
236
+ )
237
+ _rule(
238
+ "docker_missing",
239
+ MISSING_TOOL,
240
+ 90,
241
+ "Docker is required by the workflow but was not available in the runner.",
242
+ r"docker: (?:command not found|Cannot connect)",
243
+ r"Cannot connect to the Docker daemon",
244
+ )
245
+ _rule(
246
+ "git_missing",
247
+ MISSING_TOOL,
248
+ 88,
249
+ "git reported a problem resolving the requested ref.",
250
+ r"fatal: (?:not a git repository|repository .* not found|ambiguous argument)",
251
+ )
252
+
253
+
254
+ def classify(evidence: FailureEvidence) -> FailureClassification:
255
+ """Classify CI (or local) failure evidence using ordered rules."""
256
+ haystack = "\n".join(evidence.lines)
257
+ if evidence.summary:
258
+ haystack = f"{haystack}\n{evidence.summary}"
259
+
260
+ best: FailureClassification | None = None
261
+ for rule in _RULES:
262
+ for pattern in rule.patterns:
263
+ match = pattern.search(haystack)
264
+ if match:
265
+ detail = ""
266
+ groups = [g for g in match.groups() if g]
267
+ if groups:
268
+ detail = f" — {groups[0]}"
269
+ classification = FailureClassification(
270
+ category=rule.category,
271
+ confidence=rule.confidence,
272
+ kind=rule.name,
273
+ explanation=[f"{rule.explanation}{detail}"],
274
+ )
275
+ if best is None or classification.confidence > best.confidence:
276
+ best = classification
277
+ break
278
+ if best is None:
279
+ return FailureClassification(
280
+ category=UNKNOWN,
281
+ confidence=25,
282
+ explanation=[
283
+ "Replico cannot map the log output to a known failure "
284
+ "category; treat any reproduction claim with care."
285
+ ],
286
+ )
287
+ # clamp confidence when the summary did not actually match much text
288
+ if len(evidence.lines) == 0:
289
+ best.confidence = min(best.confidence, 40)
290
+ return best
291
+
292
+
293
+ def signature(evidence: FailureEvidence) -> str:
294
+ """A stable signature identifying *this* failure.
295
+
296
+ Prefers failing test ids; otherwise uses category + a normalized summary
297
+ prefix so identical errors compare equal while versions/dates do not.
298
+ """
299
+ if evidence.failing_tests:
300
+ return "tests:" + ",".join(sorted(evidence.failing_tests))
301
+ kind = evidence.category_hint or "?"
302
+ summary = re.sub(r"[0-9]+\.[0-9]+", "X.Y", evidence.summary)
303
+ normalized = re.sub(r"[^A-Za-z0-9]+", " ", summary).strip().lower()[:80]
304
+ return f"{kind}:{normalized or 'none'}"
@@ -0,0 +1,216 @@
1
+ """Smart log analysis.
2
+
3
+ Turns hundreds of lines of CI output into a handful of relevant lines.
4
+ These functions are pure (no I/O) so they can be unit tested with fixture
5
+ logs. Redaction happens at display/persistence time, not here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from replico.models import FailureEvidence
13
+
14
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
15
+ _GROUP_RE = re.compile(r"^##\[group\](.*)$")
16
+ _GROUP_END_RE = re.compile(r"^##\[endgroup\]")
17
+
18
+ _FAILED_TEST_RE = re.compile(r"^FAILED\s+([^\s]+?)(?:\s+-|$)")
19
+ _FAILED_TEST_SUMMARY_RE = re.compile(r"^=+.*failed.*=+$")
20
+
21
+ _MAX_RELEVANT_LINES = 30
22
+ _CONTEXT_BEFORE = 3
23
+ _CONTEXT_AFTER = 12
24
+
25
+
26
+ def strip_ansi(line: str) -> str:
27
+ return _ANSI_RE.sub("", line)
28
+
29
+
30
+ def split_step_segments(log: str) -> list[tuple[str | None, list[str]]]:
31
+ """Split a job log into per-step segments using GitHub's group markers.
32
+
33
+ Returns ``[(step_name_or_None, [lines...])]``. When markers are absent a
34
+ single segment covers the whole log.
35
+ """
36
+ segments: list[tuple[str | None, list[str]]] = []
37
+ current_name: str | None = None
38
+ current: list[str] = []
39
+ for raw in log.splitlines():
40
+ line = strip_ansi(raw)
41
+ match = _GROUP_RE.match(line)
42
+ if match:
43
+ if current or current_name is not None:
44
+ segments.append((current_name, current))
45
+ current = []
46
+ current_name = match.group(1).strip() or None
47
+ continue
48
+ if _GROUP_END_RE.match(line):
49
+ if current or current_name is not None:
50
+ segments.append((current_name, current))
51
+ current = []
52
+ current_name = None
53
+ continue
54
+ current.append(line)
55
+ if current or current_name is not None:
56
+ segments.append((current_name, current))
57
+ if not segments:
58
+ segments.append((None, [strip_ansi(line) for line in log.splitlines()]))
59
+ return segments
60
+
61
+
62
+ class _Finding:
63
+ __slots__ = ("kind", "line", "snippet")
64
+
65
+ def __init__(self, kind: str, line: int, snippet: list[str]) -> None:
66
+ self.kind = kind
67
+ self.line = line
68
+ self.snippet = snippet
69
+
70
+
71
+ def _python_traceback_finding(lines: list[str]) -> _Finding | None:
72
+ """Last traceback: exception summary + the frame that raised it."""
73
+ trace_indexes = [
74
+ i for i, line in enumerate(lines) if "Traceback (most recent call last)" in line
75
+ ]
76
+ if not trace_indexes:
77
+ return None
78
+ start = trace_indexes[-1]
79
+ snippet: list[str] = []
80
+ for i in range(start + 1, min(len(lines), start + 40)):
81
+ line = lines[i]
82
+ if not line.strip():
83
+ if snippet and snippet[-1].strip() == "":
84
+ break
85
+ snippet.append(line)
86
+ continue
87
+ if (
88
+ re.match(r"^\s*File \"", line)
89
+ and snippet
90
+ and re.match(r"^\s*(File \"|Traceback)", snippet[-1])
91
+ ):
92
+ # another frame; keep appending lines but only the last matters
93
+ pass
94
+ snippet.append(line)
95
+ if re.match(r"^(?:[A-Za-z_][\w.]*\.)*[A-Za-z_][\w]*Error", line.strip()):
96
+ break
97
+ snippet[-1].strip() if snippet else ""
98
+ return _Finding("traceback", start, snippet[:15])
99
+
100
+
101
+ def _findings_in(lines: list[str]) -> list[_Finding]:
102
+ findings: list[_Finding] = []
103
+ for i, line in enumerate(lines):
104
+ stripped = line.strip()
105
+ if not stripped or stripped.startswith(("##[group", "##[endgroup", "::")):
106
+ continue
107
+ if stripped.startswith("##[error]"):
108
+ findings.append(_Finding("error_marker", i, [stripped[:400]]))
109
+ continue
110
+ # pytest failure summary: FAILED path - reason
111
+ if _FAILED_TEST_RE.match(stripped):
112
+ snippet = lines[max(0, i - 1) : i + 1]
113
+ findings.append(_Finding("test_failure", i, snippet))
114
+ elif stripped.startswith("ModuleNotFoundError") or stripped.startswith("ImportError"):
115
+ findings.append(_Finding("import_error", i, [lines[max(0, i - 2)], line]))
116
+ elif stripped.startswith("##[error]"):
117
+ findings.append(_Finding("error_marker", i, [line[:400]]))
118
+ elif re.match(r"^Error: Process completed with exit code", stripped):
119
+ findings.append(_Finding("exit_code", i, [line]))
120
+ elif re.search(r"command not found|is not recognized|No such file or directory", stripped):
121
+ findings.append(_Finding("missing_command", i, [line]))
122
+ elif re.search(
123
+ r"permission denied|PermissionError|Access is denied|denied \(publickey\)", stripped
124
+ ):
125
+ findings.append(_Finding("permission", i, [line]))
126
+ elif re.search(
127
+ r"timed? ?out|The operation was canceled|OperationCanceledException", stripped, re.I
128
+ ):
129
+ findings.append(_Finding("timeout", i, [line]))
130
+ elif stripped.startswith("npm ERR!") or re.match(r"^npm error ", stripped):
131
+ findings.append(_Finding("npm_error", i, [line[:400]]))
132
+ elif re.match(r"^error(\[|:)", stripped, re.I):
133
+ findings.append(_Finding("compiler_error", i, [line[:400]]))
134
+ elif re.search(
135
+ r"error: .*Failed to download|Could not find a version|ResolutionImpossible|No matching distribution",
136
+ stripped,
137
+ ):
138
+ findings.append(_Finding("dependency_error", i, [line[:400]]))
139
+ elif stripped.startswith("Killed") or "Segmentation fault" in stripped:
140
+ findings.append(_Finding("crash", i, [line]))
141
+ tb = _python_traceback_finding(lines)
142
+ if tb is not None:
143
+ findings.append(tb)
144
+ return findings
145
+
146
+
147
+ _STRONG = {
148
+ "test_failure",
149
+ "import_error",
150
+ "dependency_error",
151
+ "timeout",
152
+ "crash",
153
+ "permission",
154
+ }
155
+
156
+
157
+ def _failing_tests(lines: list[str]) -> list[str]:
158
+ tests: list[str] = []
159
+ for line in lines:
160
+ match = _FAILED_TEST_RE.match(line.strip())
161
+ if match:
162
+ tests.append(match.group(1))
163
+ return list(dict.fromkeys(tests))
164
+
165
+
166
+ def _summary_for(lines: list[str], kind: str) -> str:
167
+ compact = [re.sub(r"\s+", " ", line).strip() for line in lines if line.strip()]
168
+ if kind == "test_failure":
169
+ return compact[-1][:300] if compact else ""
170
+ if kind == "traceback":
171
+ # exception line
172
+ return compact[-1][:300] if compact else ""
173
+ if kind == "exit_code":
174
+ return compact[-1][:200] if compact else ""
175
+ return compact[0][:300] if compact else ""
176
+
177
+
178
+ def analyze_log(log: str, source: str = "") -> FailureEvidence:
179
+ """Condense a raw CI log into focused, relevant evidence."""
180
+ lines = [strip_ansi(line) for line in log.splitlines()]
181
+ tests = _failing_tests(lines)
182
+ findings = _findings_in(lines)
183
+
184
+ evidence = FailureEvidence(source=source, failing_tests=tests)
185
+ if not findings:
186
+ tail = [line for line in lines[-12:] if line.strip()]
187
+ if tail:
188
+ evidence.lines = tail
189
+ evidence.line_numbers = list(range(len(lines) - len(tail) + 1, len(lines) + 1))
190
+ evidence.summary = "no structured error found; showing the tail of the log"
191
+ evidence.category_hint = "unknown"
192
+ return evidence
193
+
194
+ # Pick the strongest finding, preferring a pytest failure / traceback.
195
+ def rank(f: _Finding) -> tuple[int, int]:
196
+ if f.kind in _STRONG:
197
+ return (0, 0 if f.kind == "test_failure" else 1)
198
+ return (1, 0)
199
+
200
+ findings.sort(key=lambda f: (rank(f)[0], f.line))
201
+ primary = findings[0]
202
+ window: list[str] = []
203
+ indexes: list[int] = []
204
+ start = max(0, primary.line - _CONTEXT_BEFORE)
205
+ end = min(len(lines), primary.line + _CONTEXT_AFTER)
206
+ for idx in range(start, end):
207
+ if lines[idx].strip() or idx == primary.line:
208
+ window.append(lines[idx])
209
+ indexes.append(idx + 1)
210
+ if len(window) >= _MAX_RELEVANT_LINES:
211
+ break
212
+ evidence.lines = window
213
+ evidence.line_numbers = indexes
214
+ evidence.category_hint = primary.kind
215
+ evidence.summary = _summary_for(primary.snippet or window, primary.kind)
216
+ return evidence