ci-whisper 0.1.1__tar.gz
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.
- ci_whisper-0.1.1/LICENSE +21 -0
- ci_whisper-0.1.1/PKG-INFO +84 -0
- ci_whisper-0.1.1/README.md +64 -0
- ci_whisper-0.1.1/ci_whisper/__init__.py +3 -0
- ci_whisper-0.1.1/ci_whisper/cli.py +80 -0
- ci_whisper-0.1.1/ci_whisper/parse.py +215 -0
- ci_whisper-0.1.1/ci_whisper/patterns.py +74 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/PKG-INFO +84 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/SOURCES.txt +14 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/dependency_links.txt +1 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/entry_points.txt +2 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/requires.txt +3 -0
- ci_whisper-0.1.1/ci_whisper.egg-info/top_level.txt +1 -0
- ci_whisper-0.1.1/pyproject.toml +34 -0
- ci_whisper-0.1.1/setup.cfg +4 -0
- ci_whisper-0.1.1/tests/test_parse.py +123 -0
ci_whisper-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mgkvibez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ci-whisper
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Read a failed CI log and get the first real error, its causal chain, and a fix.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ci,github-actions,logs,debugging,developer-tools,agents
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# ci-whisper
|
|
22
|
+
|
|
23
|
+
**Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
|
|
24
|
+
|
|
25
|
+
[](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
[](pyproject.toml)
|
|
28
|
+
[](https://github.com/mgkvibez/ci-whisper/stargazers)
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install git+https://github.com/mgkvibez/ci-whisper.git
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
|
|
39
|
+
|
|
40
|
+
## Three ways to use it
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. The one you will use daily: pipe the failing job log
|
|
44
|
+
gh run view --log-failed | ci-whisper
|
|
45
|
+
|
|
46
|
+
# 2. From a downloaded log file
|
|
47
|
+
ci-whisper --file /tmp/build.log
|
|
48
|
+
|
|
49
|
+
# 3. Let it fetch the last failed run of the current repo
|
|
50
|
+
ci-whisper --gh 16977481234
|
|
51
|
+
|
|
52
|
+
# machine-readable, for a bot or an agent skill
|
|
53
|
+
ci-whisper --file build.log --json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit code is `1` when no error signal is found, so you can gate on it.
|
|
57
|
+
|
|
58
|
+
## Why it exists
|
|
59
|
+
|
|
60
|
+
A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
|
|
61
|
+
|
|
62
|
+
Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
|
|
67
|
+
2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
|
|
68
|
+
3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
|
|
69
|
+
4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
|
|
70
|
+
5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
|
|
71
|
+
|
|
72
|
+
No network calls, no LLM, no dependencies.
|
|
73
|
+
|
|
74
|
+
## Contributing
|
|
75
|
+
|
|
76
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m pytest -q
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ci-whisper
|
|
2
|
+
|
|
3
|
+
**Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](pyproject.toml)
|
|
8
|
+
[](https://github.com/mgkvibez/ci-whisper/stargazers)
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pipx install git+https://github.com/mgkvibez/ci-whisper.git
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
|
|
19
|
+
|
|
20
|
+
## Three ways to use it
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 1. The one you will use daily: pipe the failing job log
|
|
24
|
+
gh run view --log-failed | ci-whisper
|
|
25
|
+
|
|
26
|
+
# 2. From a downloaded log file
|
|
27
|
+
ci-whisper --file /tmp/build.log
|
|
28
|
+
|
|
29
|
+
# 3. Let it fetch the last failed run of the current repo
|
|
30
|
+
ci-whisper --gh 16977481234
|
|
31
|
+
|
|
32
|
+
# machine-readable, for a bot or an agent skill
|
|
33
|
+
ci-whisper --file build.log --json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Exit code is `1` when no error signal is found, so you can gate on it.
|
|
37
|
+
|
|
38
|
+
## Why it exists
|
|
39
|
+
|
|
40
|
+
A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
|
|
41
|
+
|
|
42
|
+
Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
|
|
43
|
+
|
|
44
|
+
## How it works
|
|
45
|
+
|
|
46
|
+
1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
|
|
47
|
+
2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
|
|
48
|
+
3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
|
|
49
|
+
4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
|
|
50
|
+
5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
|
|
51
|
+
|
|
52
|
+
No network calls, no LLM, no dependencies.
|
|
53
|
+
|
|
54
|
+
## Contributing
|
|
55
|
+
|
|
56
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python -m pytest -q
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command line entry point for ci-whisper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .parse import Diagnosis, diagnose
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_log(args: argparse.Namespace) -> str:
|
|
16
|
+
"""Get the log text from a file, stdin, or `gh run view`."""
|
|
17
|
+
if args.file:
|
|
18
|
+
return Path(args.file).read_text(encoding="utf-8", errors="replace")
|
|
19
|
+
if args.gh:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
["gh", "run", "view", args.gh, "--log-failed"],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
check=False,
|
|
25
|
+
)
|
|
26
|
+
if result.returncode != 0 and not result.stdout:
|
|
27
|
+
print(f"ci-whisper: gh failed: {result.stderr.strip()}", file=sys.stderr)
|
|
28
|
+
raise SystemExit(2)
|
|
29
|
+
return result.stdout
|
|
30
|
+
if sys.stdin.isatty():
|
|
31
|
+
print("ci-whisper: pass a log on stdin, --file, or --gh <run-id>", file=sys.stderr)
|
|
32
|
+
raise SystemExit(2)
|
|
33
|
+
return sys.stdin.read()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(diagnosis: Diagnosis) -> str:
|
|
37
|
+
"""Human-readable report."""
|
|
38
|
+
out: list[str] = []
|
|
39
|
+
if diagnosis.root_cause is None:
|
|
40
|
+
out.append("No error found in this log.")
|
|
41
|
+
out.append(f"Scanned {diagnosis.scanned} lines, {diagnosis.signals} signals.")
|
|
42
|
+
return "\n".join(out)
|
|
43
|
+
|
|
44
|
+
root = diagnosis.root_cause
|
|
45
|
+
out.append("FIRST REAL ERROR")
|
|
46
|
+
out.append(f" line {root.line_no}: {root.text}")
|
|
47
|
+
out.append("")
|
|
48
|
+
out.append("CAUSAL CHAIN")
|
|
49
|
+
for hit in diagnosis.chain:
|
|
50
|
+
role = "root" if hit is root else hit.kind
|
|
51
|
+
out.append(f" {role:12} line {hit.line_no}: {hit.text}")
|
|
52
|
+
out.append("")
|
|
53
|
+
out.append("SUGGESTED FIX")
|
|
54
|
+
out.append(f" {diagnosis.fix}")
|
|
55
|
+
out.append("")
|
|
56
|
+
out.append(f"Scanned {diagnosis.scanned} lines, {diagnosis.signals} signals.")
|
|
57
|
+
return "\n".join(out)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> int:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="ci-whisper",
|
|
63
|
+
description="Read a failed CI log and report the first real error, its chain, and a fix.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--file", "-f", help="log file to read")
|
|
66
|
+
parser.add_argument("--gh", metavar="RUN_ID", help="GitHub Actions run id (uses gh run view)")
|
|
67
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
68
|
+
parser.add_argument("--version", action="version", version=f"ci-whisper {__version__}")
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
diagnosis = diagnose(read_log(args))
|
|
72
|
+
if args.json:
|
|
73
|
+
print(json.dumps(diagnosis.as_dict(), indent=2))
|
|
74
|
+
else:
|
|
75
|
+
print(render(diagnosis))
|
|
76
|
+
return 0 if diagnosis.root_cause else 1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Turn a raw CI log into a diagnosis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
|
|
9
|
+
from .patterns import (
|
|
10
|
+
ANSI_RE,
|
|
11
|
+
CAUSAL_HINTS,
|
|
12
|
+
FIX_RULES,
|
|
13
|
+
GO_COMPILER_ERROR_RE,
|
|
14
|
+
GO_PACKAGE_FAILURE_RE,
|
|
15
|
+
GO_TEST_FAILURE_RE,
|
|
16
|
+
NOISE,
|
|
17
|
+
STRONG_ERRORS,
|
|
18
|
+
TIMESTAMP_RE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Hit:
|
|
24
|
+
"""One signal found in the log."""
|
|
25
|
+
|
|
26
|
+
line_no: int
|
|
27
|
+
text: str
|
|
28
|
+
kind: str
|
|
29
|
+
strength: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Diagnosis:
|
|
34
|
+
"""Result of reading a log."""
|
|
35
|
+
|
|
36
|
+
root_cause: Hit | None
|
|
37
|
+
chain: list[Hit] = field(default_factory=list)
|
|
38
|
+
fix: str = ""
|
|
39
|
+
phase: str = ""
|
|
40
|
+
scanned: int = 0
|
|
41
|
+
signals: int = 0
|
|
42
|
+
|
|
43
|
+
def as_dict(self) -> dict[str, object]:
|
|
44
|
+
return {
|
|
45
|
+
"phase": self.phase,
|
|
46
|
+
"scanned_lines": self.scanned,
|
|
47
|
+
"signals": self.signals,
|
|
48
|
+
"root_cause": (
|
|
49
|
+
{
|
|
50
|
+
"line": self.root_cause.line_no,
|
|
51
|
+
"kind": self.root_cause.kind,
|
|
52
|
+
"text": self.root_cause.text,
|
|
53
|
+
}
|
|
54
|
+
if self.root_cause
|
|
55
|
+
else None
|
|
56
|
+
),
|
|
57
|
+
"chain": [
|
|
58
|
+
{"line": h.line_no, "kind": h.kind, "text": h.text} for h in self.chain
|
|
59
|
+
],
|
|
60
|
+
"suggested_fix": self.fix,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clean_lines(raw: str) -> list[str]:
|
|
65
|
+
"""Strip ANSI escapes and CI timestamps, keeping one entry per log line."""
|
|
66
|
+
out: list[str] = []
|
|
67
|
+
for line in raw.splitlines():
|
|
68
|
+
line = ANSI_RE.sub("", line)
|
|
69
|
+
line = TIMESTAMP_RE.sub("", line)
|
|
70
|
+
out.append(line.rstrip("\r"))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_noise(line: str) -> bool:
|
|
75
|
+
"""True when a line looks like an error but is not worth reporting."""
|
|
76
|
+
return any(pattern.search(line) for pattern in NOISE)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def classify(line: str) -> tuple[str, int] | None:
|
|
80
|
+
"""Return (kind, strength) for a matching error line, else None."""
|
|
81
|
+
text = line.strip()
|
|
82
|
+
if not text or is_noise(text):
|
|
83
|
+
return None
|
|
84
|
+
# Go compiler diagnostics have no "error:" prefix. Keep indentation here:
|
|
85
|
+
# a test's indented source location is context, not a compiler error.
|
|
86
|
+
if GO_COMPILER_ERROR_RE.search(line):
|
|
87
|
+
return "compiler-error", 3
|
|
88
|
+
for kind, pattern in STRONG_ERRORS:
|
|
89
|
+
if pattern.search(text):
|
|
90
|
+
# The exception line is the cause; the Traceback header is only its preface.
|
|
91
|
+
if kind in {"module-not-found", "exception"}:
|
|
92
|
+
strength = 4
|
|
93
|
+
elif kind in {"python-traceback", "test-failure", "go-test-failure", "compiler-error"}:
|
|
94
|
+
strength = 3
|
|
95
|
+
elif kind in {"npm-error", "github-error"}:
|
|
96
|
+
strength = 2
|
|
97
|
+
else:
|
|
98
|
+
strength = 1
|
|
99
|
+
return kind, strength
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_signals(lines: Iterable[str]) -> list[Hit]:
|
|
104
|
+
"""Collect every non-noise error signal with its original line number."""
|
|
105
|
+
hits: list[Hit] = []
|
|
106
|
+
for index, line in enumerate(lines, start=1):
|
|
107
|
+
found = classify(line)
|
|
108
|
+
if found:
|
|
109
|
+
kind, strength = found
|
|
110
|
+
hits.append(Hit(line_no=index, text=line.strip(), kind=kind, strength=strength))
|
|
111
|
+
return hits
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_phase(lines: list[str]) -> str:
|
|
115
|
+
"""Name the step that was running when the first signal appeared."""
|
|
116
|
+
current = ""
|
|
117
|
+
for line in lines:
|
|
118
|
+
stripped = line.strip()
|
|
119
|
+
if stripped.startswith("##[group]"):
|
|
120
|
+
current = stripped[len("##[group]") :].strip() or current
|
|
121
|
+
elif stripped.startswith("##[endgroup]"):
|
|
122
|
+
continue
|
|
123
|
+
return current
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def pick_root_cause(hits: list[Hit]) -> Hit | None:
|
|
127
|
+
"""Choose the earliest strong signal; prefer structured errors over exit codes."""
|
|
128
|
+
if not hits:
|
|
129
|
+
return None
|
|
130
|
+
structured = [h for h in hits if h.strength >= 3]
|
|
131
|
+
if structured:
|
|
132
|
+
# Highest severity first, then earliest line: the exception beats its own preface.
|
|
133
|
+
return sorted(structured, key=lambda h: (-h.strength, h.line_no))[0]
|
|
134
|
+
non_exit = [h for h in hits if h.kind != "non-zero-exit"]
|
|
135
|
+
return (non_exit or hits)[0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_chain(root: Hit | None, hits: list[Hit], lines: list[str]) -> list[Hit]:
|
|
139
|
+
"""Collect related signals: the root cause, its context, and later consequences."""
|
|
140
|
+
if root is None:
|
|
141
|
+
return []
|
|
142
|
+
chain: list[Hit] = [root]
|
|
143
|
+
downstream = [h for h in hits if h.line_no > root.line_no][:3]
|
|
144
|
+
chain.extend(downstream)
|
|
145
|
+
for offset in range(root.line_no, min(root.line_no + 12, len(lines) + 1)):
|
|
146
|
+
text = lines[offset - 1].strip()
|
|
147
|
+
if offset == root.line_no:
|
|
148
|
+
continue
|
|
149
|
+
if any(hint.search(text) for hint in CAUSAL_HINTS) and not is_noise(text):
|
|
150
|
+
chain.append(Hit(line_no=offset, text=text, kind="causal", strength=1))
|
|
151
|
+
break
|
|
152
|
+
seen: set[int] = set()
|
|
153
|
+
unique: list[Hit] = []
|
|
154
|
+
for hit in chain:
|
|
155
|
+
if hit.line_no not in seen:
|
|
156
|
+
seen.add(hit.line_no)
|
|
157
|
+
unique.append(hit)
|
|
158
|
+
return unique
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
TEST_ID_RE = re.compile(r"^(?:FAILED\s+)?(?P<test>[\w./-]+\.py::[\w:.\[\]-]+)")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def suggest_fix(root: Hit | None, lines: list[str] | None = None) -> str:
|
|
165
|
+
"""Map the root cause to a concrete first action."""
|
|
166
|
+
if root is None:
|
|
167
|
+
return "No error signal found. The log may be truncated or the failure is a cancelled step."
|
|
168
|
+
if root.kind == "go-test-failure":
|
|
169
|
+
match = GO_TEST_FAILURE_RE.search(root.text)
|
|
170
|
+
if match:
|
|
171
|
+
package = ""
|
|
172
|
+
for line in (lines or [])[root.line_no:]:
|
|
173
|
+
text = line.strip()
|
|
174
|
+
if text.startswith(("##[group]", "##[endgroup]", "Run ")):
|
|
175
|
+
break
|
|
176
|
+
result = GO_PACKAGE_FAILURE_RE.search(text)
|
|
177
|
+
if result:
|
|
178
|
+
package = f" in package {result.group(1)}"
|
|
179
|
+
break
|
|
180
|
+
return (
|
|
181
|
+
f"Go test {match.group(1)}{package} failed. Re-run it alone and inspect "
|
|
182
|
+
"its assertion output, then fix the expectation or the code it exercises."
|
|
183
|
+
)
|
|
184
|
+
if root.kind == "test-failure":
|
|
185
|
+
match = TEST_ID_RE.search(root.text)
|
|
186
|
+
if match:
|
|
187
|
+
detail = root.text.split(" - ", 1)[1] if " - " in root.text else root.text
|
|
188
|
+
return (
|
|
189
|
+
f"{match.group('test')} failed with {detail}. Run it alone to reproduce, "
|
|
190
|
+
"then fix the expectation or the code it exercises."
|
|
191
|
+
)
|
|
192
|
+
for pattern, template in FIX_RULES:
|
|
193
|
+
match = pattern.search(root.text)
|
|
194
|
+
if match:
|
|
195
|
+
groups = [g for g in match.groups() if g]
|
|
196
|
+
return template.format(*groups) if groups else template
|
|
197
|
+
return (
|
|
198
|
+
f"Read {root.text!r} at line {root.line_no}. The first real error is usually "
|
|
199
|
+
"the one before the cascade of non-zero exits."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def diagnose(raw: str) -> Diagnosis:
|
|
204
|
+
"""Full pipeline: clean, find signals, pick root cause, chain, fix."""
|
|
205
|
+
lines = clean_lines(raw)
|
|
206
|
+
hits = find_signals(lines)
|
|
207
|
+
root = pick_root_cause(hits)
|
|
208
|
+
return Diagnosis(
|
|
209
|
+
root_cause=root,
|
|
210
|
+
chain=build_chain(root, hits, lines),
|
|
211
|
+
fix=suggest_fix(root, lines),
|
|
212
|
+
phase=find_phase(lines),
|
|
213
|
+
scanned=len(lines),
|
|
214
|
+
signals=len(hits),
|
|
215
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Signal and noise patterns for CI log classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
8
|
+
TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?")
|
|
9
|
+
JOB_PREFIX_RE = re.compile(r"^[^\t]*\t(?=[a-z-]+\t)")
|
|
10
|
+
GO_TEST_FAILURE_RE = re.compile(r"^--- FAIL: (\S+) \(\d+(?:\.\d+)?s\)$")
|
|
11
|
+
GO_PACKAGE_FAILURE_RE = re.compile(r"^FAIL\s+(\S+)\s+(?:\d+(?:\.\d+)?s|\[build failed\])(?:\s|$)")
|
|
12
|
+
GO_COMPILER_ERROR_RE = re.compile(r"^\S+\.go:\d+:\d+:\s+\S")
|
|
13
|
+
|
|
14
|
+
# Errors that are almost always the real cause.
|
|
15
|
+
STRONG_ERRORS: list[tuple[str, re.Pattern[str]]] = [
|
|
16
|
+
("python-traceback", re.compile(r"^Traceback \(most recent call last\):")),
|
|
17
|
+
("module-not-found", re.compile(r"^ModuleNotFoundError:|^ImportError:")),
|
|
18
|
+
("npm-error", re.compile(r"^npm ERR!")),
|
|
19
|
+
("github-error", re.compile(r"^##\[error\]")),
|
|
20
|
+
("compiler-error", re.compile(r"\berror(\[E\d+\])?:")),
|
|
21
|
+
("go-test-failure", GO_TEST_FAILURE_RE),
|
|
22
|
+
("test-failure", re.compile(r"^(FAILED|FAIL|✕|×)\b")),
|
|
23
|
+
("fatal", re.compile(r"^fatal:")),
|
|
24
|
+
("exception", re.compile(r"^[A-Za-z_.]+(Error|Exception):\s")),
|
|
25
|
+
("non-zero-exit", re.compile(r"process completed with exit code [1-9]")),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Lines that look like errors but are noise.
|
|
29
|
+
NOISE: list[re.Pattern[str]] = [
|
|
30
|
+
re.compile(r"\b\d+\s+(passed|failed)=?\s*0 failed", re.IGNORECASE),
|
|
31
|
+
re.compile(r"^warning:?|^W\d{4}\b|\bWARN\b", re.IGNORECASE),
|
|
32
|
+
re.compile(r"deprecat", re.IGNORECASE),
|
|
33
|
+
re.compile(r"^\s*$"),
|
|
34
|
+
re.compile(r"^\s*\d+%|\bDownloading\b|\bCollecting\b", re.IGNORECASE),
|
|
35
|
+
re.compile(r"^npm WARN", re.IGNORECASE),
|
|
36
|
+
re.compile(r"\b0 errors?\b", re.IGNORECASE),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Markers that a previous phase ended; errors after these are the interesting ones.
|
|
40
|
+
PHASE_MARKERS: list[re.Pattern[str]] = [
|
|
41
|
+
re.compile(r"^##\[group\](.*)$"),
|
|
42
|
+
re.compile(r"^Run\s+(.+)$"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
CAUSAL_HINTS: list[re.Pattern[str]] = [
|
|
46
|
+
re.compile(r"caused by:", re.IGNORECASE),
|
|
47
|
+
re.compile(r"\bduring handling of the above exception", re.IGNORECASE),
|
|
48
|
+
re.compile(r"\bdue to\b", re.IGNORECASE),
|
|
49
|
+
re.compile(r"^The above exception was the direct cause", re.IGNORECASE),
|
|
50
|
+
re.compile(r"^\s+npm ERR!.*\n?", re.IGNORECASE),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
FIX_RULES: list[tuple[re.Pattern[str], str]] = [
|
|
54
|
+
(re.compile(r"ModuleNotFoundError: No module named '([^']+)'"),
|
|
55
|
+
"Install the missing module (pip install {0}) and add it to your dependencies so CI installs it."),
|
|
56
|
+
(re.compile(r"npm ERR! code ERESOLVE"),
|
|
57
|
+
"Peer dependency conflict: align the conflicting versions or use a compatible lockfile."),
|
|
58
|
+
(re.compile(r"npm ERR!.*ETARGET|No matching version found for (\S+)"),
|
|
59
|
+
"The requested version does not exist in the registry. Pin an existing version."),
|
|
60
|
+
(re.compile(r"Permission denied \(publickey\)|Host key verification failed"),
|
|
61
|
+
"The deploy key or known_hosts entry is missing or rotated. Check repository secrets."),
|
|
62
|
+
(re.compile(r"No such file or directory: '([^']+)'"),
|
|
63
|
+
"A required file is missing at {0}. Verify the checkout path and that the file is committed."),
|
|
64
|
+
(re.compile(r"EADDRINUSE|address already in use", re.IGNORECASE),
|
|
65
|
+
"Port conflict: the service is already bound. Kill the stale process or choose a free port."),
|
|
66
|
+
(re.compile(r"AssertionError"),
|
|
67
|
+
"An assertion failed: the actual value differs from the expected one. Read the assertion line for both values."),
|
|
68
|
+
(re.compile(r"SyntaxError"),
|
|
69
|
+
"The file does not parse. Fix the syntax before investigating anything downstream."),
|
|
70
|
+
(re.compile(r"Timeout|timed out", re.IGNORECASE),
|
|
71
|
+
"The step exceeded its time budget. Look for a hang or an unbounded network call."),
|
|
72
|
+
(re.compile(r"Killed|signal: killed|OutOfMemory", re.IGNORECASE),
|
|
73
|
+
"The process was killed, usually out of memory. Reduce memory use or raise the runner limit."),
|
|
74
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ci-whisper
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Read a failed CI log and get the first real error, its causal chain, and a fix.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ci,github-actions,logs,debugging,developer-tools,agents
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# ci-whisper
|
|
22
|
+
|
|
23
|
+
**Read a failed CI log. Get the first real error, its causal chain, and a fix.** One paragraph instead of ten thousand lines of noise.
|
|
24
|
+
|
|
25
|
+
[](https://github.com/mgkvibez/ci-whisper/actions/workflows/ci.yml)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
[](pyproject.toml)
|
|
28
|
+
[](https://github.com/mgkvibez/ci-whisper/stargazers)
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pipx install git+https://github.com/mgkvibez/ci-whisper.git
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Until it is on PyPI, install from git. For development: `git clone && pip install -e ".[dev]"`.
|
|
39
|
+
|
|
40
|
+
## Three ways to use it
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# 1. The one you will use daily: pipe the failing job log
|
|
44
|
+
gh run view --log-failed | ci-whisper
|
|
45
|
+
|
|
46
|
+
# 2. From a downloaded log file
|
|
47
|
+
ci-whisper --file /tmp/build.log
|
|
48
|
+
|
|
49
|
+
# 3. Let it fetch the last failed run of the current repo
|
|
50
|
+
ci-whisper --gh 16977481234
|
|
51
|
+
|
|
52
|
+
# machine-readable, for a bot or an agent skill
|
|
53
|
+
ci-whisper --file build.log --json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Exit code is `1` when no error signal is found, so you can gate on it.
|
|
57
|
+
|
|
58
|
+
## Why it exists
|
|
59
|
+
|
|
60
|
+
A red build hands you 10k lines. The actual cause is line 4, and everything after it is consequence: retries, cascading test failures, the polite `##[error]Process completed with exit code 1`.
|
|
61
|
+
|
|
62
|
+
Two things go wrong when this hits an agent. It reads the *last* error and fixes a symptom, or it reads the header and summarises nothing. ci-whisper ranks signals so the cause outranks its own consequences, then shows the citations so anyone can check the call.
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
1. **Clean** — strips ANSI and GitHub's `2026-10-29T08:01:02.1234567Z ` timestamp prefix, keeping a line-number map to the original file.
|
|
67
|
+
2. **Scan** — matches a ranked rule set: `ModuleNotFoundError`/`Exception` (4) beat `Traceback`, test failures and compiler errors (3), which beat `npm ERR!` and `##[error]` (2), which beat a bare `exit code 1` (1).
|
|
68
|
+
3. **Root cause** — highest severity, then earliest line. Noise (warnings, deprecations, `0 failed`, download progress) is filtered before ranking.
|
|
69
|
+
4. **Chain** — the chosen line plus the following block: indented continuations, frames, and `caused by` style links.
|
|
70
|
+
5. **Fix** — a rule per failure shape: peer dependency conflict, missing module, permissions, missing path, assertion, timeout, OOM.
|
|
71
|
+
|
|
72
|
+
No network calls, no LLM, no dependencies.
|
|
73
|
+
|
|
74
|
+
## Contributing
|
|
75
|
+
|
|
76
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md). Tests:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m pytest -q
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
ci_whisper/__init__.py
|
|
5
|
+
ci_whisper/cli.py
|
|
6
|
+
ci_whisper/parse.py
|
|
7
|
+
ci_whisper/patterns.py
|
|
8
|
+
ci_whisper.egg-info/PKG-INFO
|
|
9
|
+
ci_whisper.egg-info/SOURCES.txt
|
|
10
|
+
ci_whisper.egg-info/dependency_links.txt
|
|
11
|
+
ci_whisper.egg-info/entry_points.txt
|
|
12
|
+
ci_whisper.egg-info/requires.txt
|
|
13
|
+
ci_whisper.egg-info/top_level.txt
|
|
14
|
+
tests/test_parse.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ci_whisper
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ci-whisper"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Read a failed CI log and get the first real error, its causal chain, and a fix."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "mgkvibez" }]
|
|
13
|
+
keywords = ["ci", "github-actions", "logs", "debugging", "developer-tools", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["pytest>=8"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
ci-whisper = "ci_whisper.cli:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["ci_whisper"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tests for ci_whisper.parse."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ci_whisper.parse import classify, clean_lines, diagnose, suggest_fix
|
|
6
|
+
|
|
7
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load(name: str) -> str:
|
|
11
|
+
return (FIXTURES / name).read_text(encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_pytest_failure_finds_assertion_not_exit_code():
|
|
15
|
+
diagnosis = diagnose(load("pytest_fail.log"))
|
|
16
|
+
assert diagnosis.root_cause is not None
|
|
17
|
+
assert diagnosis.root_cause.kind == "test-failure"
|
|
18
|
+
assert "test_render_header" in diagnosis.root_cause.text
|
|
19
|
+
assert "test_render_header" in diagnosis.fix
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_npm_failure_reports_eresolve_and_ignores_deprecation():
|
|
23
|
+
diagnosis = diagnose(load("npm_fail.log"))
|
|
24
|
+
assert diagnosis.root_cause is not None
|
|
25
|
+
assert diagnosis.root_cause.kind == "npm-error"
|
|
26
|
+
assert "ERESOLVE" in diagnosis.root_cause.text
|
|
27
|
+
assert "Peer dependency" in diagnosis.fix
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_traceback_uses_module_not_found_rule():
|
|
31
|
+
diagnosis = diagnose(load("traceback.log"))
|
|
32
|
+
assert diagnosis.root_cause is not None
|
|
33
|
+
assert "No module named 'yaml'" in diagnosis.root_cause.text
|
|
34
|
+
assert "pip install yaml" in diagnosis.fix
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_noise_only_log_reports_no_error():
|
|
38
|
+
diagnosis = diagnose(load("noise_only.log"))
|
|
39
|
+
assert diagnosis.root_cause is None
|
|
40
|
+
assert "No error signal found" in diagnosis.fix
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_line_numbers_point_at_the_source_line():
|
|
44
|
+
raw = load("pytest_fail.log")
|
|
45
|
+
diagnosis = diagnose(raw)
|
|
46
|
+
lines = clean_lines(raw)
|
|
47
|
+
root = diagnosis.root_cause
|
|
48
|
+
assert root is not None
|
|
49
|
+
assert lines[root.line_no - 1].strip() == root.text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_chain_is_ordered_and_unique():
|
|
53
|
+
diagnosis = diagnose(load("pytest_fail.log"))
|
|
54
|
+
numbers = [hit.line_no for hit in diagnosis.chain]
|
|
55
|
+
assert numbers == sorted(numbers)
|
|
56
|
+
assert len(numbers) == len(set(numbers))
|
|
57
|
+
assert diagnosis.chain[0].line_no == diagnosis.root_cause.line_no
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_suggest_fix_handles_none():
|
|
61
|
+
assert suggest_fix(None).startswith("No error signal found")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_exit_code_only_log_falls_back_to_exit_signal():
|
|
65
|
+
diagnosis = diagnose("##[error]Process completed with exit code 1.")
|
|
66
|
+
assert diagnosis.root_cause is not None
|
|
67
|
+
assert diagnosis.root_cause.kind == "github-error"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_go_failure_names_test_and_package_not_summary():
|
|
71
|
+
diagnosis = diagnose(load("go_fail.log"))
|
|
72
|
+
assert diagnosis.root_cause is not None
|
|
73
|
+
assert diagnosis.root_cause.kind == "go-test-failure"
|
|
74
|
+
assert diagnosis.root_cause.strength == 3
|
|
75
|
+
assert diagnosis.root_cause.line_no == 3
|
|
76
|
+
assert diagnosis.root_cause.text == "--- FAIL: TestTotal (0.00s)"
|
|
77
|
+
assert "TestTotal" in diagnosis.fix
|
|
78
|
+
assert "example.com/shop/cart" in diagnosis.fix
|
|
79
|
+
assert "example.com/shop/cache" not in diagnosis.fix
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_go_failure_does_not_outrank_earlier_compiler_error():
|
|
83
|
+
raw = (
|
|
84
|
+
"2026-10-29T08:01:01.0000000Z # example.com/shop/broken\n"
|
|
85
|
+
"2026-10-29T08:01:01.0000000Z broken/main.go:3:9: undefined: missing\n"
|
|
86
|
+
"2026-10-29T08:01:01.0000000Z FAIL\texample.com/shop/broken [build failed]\n"
|
|
87
|
+
+ load("go_fail.log")
|
|
88
|
+
)
|
|
89
|
+
diagnosis = diagnose(raw)
|
|
90
|
+
assert diagnosis.root_cause is not None
|
|
91
|
+
assert diagnosis.root_cause.kind == "compiler-error"
|
|
92
|
+
assert diagnosis.root_cause.line_no == 2
|
|
93
|
+
assert "undefined: missing" in diagnosis.root_cause.text
|
|
94
|
+
assert "TestTotal" not in diagnosis.fix
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_go_subtest_and_missing_package_summary():
|
|
98
|
+
diagnosis = diagnose(
|
|
99
|
+
" --- FAIL: TestTotal/discount (0.01s)\n"
|
|
100
|
+
" cart_test.go:12: total = 12, want 10\n"
|
|
101
|
+
)
|
|
102
|
+
assert diagnosis.root_cause is not None
|
|
103
|
+
assert diagnosis.root_cause.kind == "go-test-failure"
|
|
104
|
+
assert "TestTotal/discount" in diagnosis.fix
|
|
105
|
+
assert "package" not in diagnosis.fix
|
|
106
|
+
assert suggest_fix(diagnosis.root_cause) == diagnosis.fix
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_go_package_lookup_stops_at_next_ci_step():
|
|
110
|
+
diagnosis = diagnose(
|
|
111
|
+
"--- FAIL: TestTotal (0.00s)\n"
|
|
112
|
+
"##[endgroup]\n"
|
|
113
|
+
"##[group]Run go test ./other\n"
|
|
114
|
+
"FAIL\texample.com/other\t0.001s\n"
|
|
115
|
+
)
|
|
116
|
+
assert "TestTotal" in diagnosis.fix
|
|
117
|
+
assert "example.com/other" not in diagnosis.fix
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_go_passing_tests_and_indented_source_locations_are_not_errors():
|
|
121
|
+
assert classify("--- PASS: TestTotal (0.00s)") is None
|
|
122
|
+
assert classify(" cart_test.go:12:9: a diagnostic from a test") is None
|
|
123
|
+
assert classify("--- FAIL: this is not a Go test result") is None
|