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 +7 -0
- replico/__main__.py +6 -0
- replico/analysis/__init__.py +1 -0
- replico/analysis/classifier.py +304 -0
- replico/analysis/logs.py +216 -0
- replico/cli.py +339 -0
- replico/cmds.py +298 -0
- replico/config.py +189 -0
- replico/environments/__init__.py +25 -0
- replico/environments/base.py +64 -0
- replico/environments/fingerprint.py +222 -0
- replico/environments/node.py +35 -0
- replico/environments/python.py +289 -0
- replico/errors.py +80 -0
- replico/execution/__init__.py +1 -0
- replico/execution/docker.py +166 -0
- replico/execution/runner.py +212 -0
- replico/flows.py +840 -0
- replico/github/__init__.py +1 -0
- replico/github/client.py +258 -0
- replico/github/refs.py +76 -0
- replico/gitrepo.py +179 -0
- replico/models.py +157 -0
- replico/pipeline.py +740 -0
- replico/security/__init__.py +34 -0
- replico/security/guard.py +122 -0
- replico/security/redaction.py +270 -0
- replico/storage/__init__.py +1 -0
- replico/storage/store.py +201 -0
- replico/ui.py +146 -0
- replico/util.py +136 -0
- replico/workflow/__init__.py +1 -0
- replico/workflow/detector.py +271 -0
- replico/workflow/matcher.py +101 -0
- replico/workflow/parser.py +363 -0
- replico-0.1.0.dist-info/METADATA +447 -0
- replico-0.1.0.dist-info/RECORD +40 -0
- replico-0.1.0.dist-info/WHEEL +4 -0
- replico-0.1.0.dist-info/entry_points.txt +2 -0
- replico-0.1.0.dist-info/licenses/LICENSE +21 -0
replico/__init__.py
ADDED
replico/__main__.py
ADDED
|
@@ -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'}"
|
replico/analysis/logs.py
ADDED
|
@@ -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
|