unflake 0.6.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.
unflake-0.6.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Unflake Contributors
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.
unflake-0.6.1/PKG-INFO ADDED
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: unflake
3
+ Version: 0.6.1
4
+ Summary: Green CI without the rerun ritual — detect, score, and quarantine flaky tests
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/YOUR-USER/unflake
7
+ Project-URL: Issues, https://github.com/YOUR-USER/unflake/issues
8
+ Keywords: testing,flaky-tests,ci,qa,agent-skills
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # Unflake 🟢
20
+
21
+ [![CI](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml/badge.svg)](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml)
22
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/YOUR-USER/unflake/badge)](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/unflake)
23
+ [![PyPI](https://img.shields.io/pypi/v/unflake.svg)](https://pypi.org/project/unflake/)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
25
+
26
+ **Green CI without the "just rerun it" ritual.**
27
+
28
+ ![Unflake 60-second demo](assets/terminal-demo.svg)
29
+
30
+ CI is red. You rerun. It's green. You merge, trusting nothing. Unflake hunts the flake instead: static scan in milliseconds, FlakeScore across repeated runs, regression-vs-flake verdicts, and quarantine configs that keep the build green *without deleting a single test*.
31
+
32
+ ![scan → run → calm](assets/pipeline.svg)
33
+
34
+ ```bash
35
+ pip install unflake
36
+ unflake scan tests/ # 1. smells, instantly
37
+ unflake run --runs 3 --runner pytest -- pytest tests/ -q # 2. collect + score
38
+ unflake quarantine run1.xml run2.xml --framework pytest # 3. quarantine, don't delete
39
+ unflake init --framework pytest --write # 4. seeded RNG scaffold (prevention)
40
+ ```
41
+
42
+ JS-first repo? Same tool, no Python packaging — just Python itself (3.9+):
43
+
44
+ ```bash
45
+ npx unflake-ci scan tests/
46
+ npx unflake-ci run --runs 3 --runner vitest -- npx vitest run tests/
47
+ ```
48
+
49
+ Proven on real suites: 3,676 tests × 5 runs across attrs/click/tqdm → FlakeScore 100,
50
+ 0 false quarantines ([study](benchmarks/BENCH.md)).
51
+
52
+ No install handy? `bash demo.sh` runs the whole loop on fixtures in 60 seconds.
53
+
54
+ > ⭐ If flaky tests have ever paged you, star this — it helps other devs find it.
55
+
56
+ ## Before / after
57
+
58
+ ```python
59
+ # before: the 3am pager
60
+ def test_checkout():
61
+ time.sleep(5) # pray the server is up
62
+ assert requests.get(API).ok # real network in a unit test
63
+ ```
64
+
65
+ ```bash
66
+ $ unflake scan tests/
67
+ WARNING [FLK001] tests/test_shop.py:4 — time.sleep() in tests makes timing-dependent flakes
68
+ fix: Replace with polling (wait_for / waitFor / eventually) with a timeout, or freeze time.
69
+ WARNING [FLK004] tests/test_shop.py:5 — Real network calls in tests fail without mocks ...
70
+ ```
71
+
72
+ ```bash
73
+ $ unflake run --runs 3 -- pytest tests/ -q
74
+ FlakeScore: 50.0/100 (2 flaky of 4 tests)
75
+ FLAKY test_cart::test_checkout (2P/1F over 3 runs)
76
+ FLAKY test_cart::test_discount (2P/1F over 3 runs)
77
+ ```
78
+
79
+ ```bash
80
+ $ unflake quarantine run1.xml run2.xml --framework pytest
81
+ pytest -k "not test_checkout and not test_discount" # gating run stays green
82
+ pytest -k "test_checkout or test_discount" # nightly run still reports
83
+ ```
84
+
85
+ Quarantine preserves signal; deletion hides it. Every quarantined test still runs — it just can't fail the build. **Regressions are never quarantined**: green-then-red-forever is a real failure — fix it.
86
+
87
+ ## Flaky or regression? Unflake knows the difference
88
+
89
+ Pass result files oldest → newest and Unflake separates three fates:
90
+
91
+ | Verdict | Meaning | Action |
92
+ |---|---|---|
93
+ | `FLAKY` | mixed outcomes, no clean break | quarantine + fix root cause |
94
+ | `REGRESSION` | green until run N, red ever since | fix now — never quarantine |
95
+ | `NEW` / stable | seen once / always green / always red | more runs / ship it / real bug |
96
+
97
+ Two runs can only suggest a flake; calling a regression needs ≥3. One run proves nothing — `unflake run` exists so there's no excuse.
98
+
99
+ ## Why not the others?
100
+
101
+ | | Unflake | Heavy flake platforms |
102
+ |---|---|---|
103
+ | Install | zero-dep, stdlib only, `pip install unflake` | OTel collectors, dashboards, SaaS |
104
+ | Input | JUnit XML from **any** framework (pytest ✅, Vitest ✅, Playwright ✅, Jest ✅ e2e — plus generic `--junit-flag`, Go, JUnit…) | per-framework reporters/plugins |
105
+ | First value | `scan` in milliseconds, no execution | needs history ingestion |
106
+ | Verdicts | flaky vs regression vs new, change-point included | usually just a score |
107
+ | Philosophy | quarantine, never delete | often auto-skip-and-forget |
108
+ | Agent-native | `SKILL.md` + harness adapters day one | docs page, if you're lucky |
109
+
110
+ ## Works with your harness
111
+
112
+ Claude Code · Codex · Copilot · Cursor · Gemini · Pi · OpenCode · Windsurf · Cline · Qoder — plus a GitHub Action (`action.yml`, SARIF → code scanning) and a pre-commit hook. Details: [`docs/ADAPTERS.md`](docs/ADAPTERS.md). The skill lives at [`skills/unflake/SKILL.md`](skills/unflake/SKILL.md).
113
+
114
+ 10 rules today, incl. the JS classics: `cy.wait(ms)` (FLK009) and `waitForTimeout` (FLK010).
115
+ Precision features: localhost-aware severities, string-literal awareness (Python),
116
+ `--exclude` globs, and SARIF rule metadata for code scanning.
117
+
118
+ ## Benchmarks (honest, reproducible)
119
+
120
+ Fixtures + real pytest e2e + a real-repo scan (`psf/requests`: 120 findings in ~0.1s — with the noise documented, not hidden). Method, numbers, and known limitations: [`benchmarks/BENCH.md`](benchmarks/BENCH.md). If a number doesn't reproduce, that's a bug — file it.
121
+
122
+ ## Contributing — built for drive-by PRs
123
+
124
+ - 🧪 New FLK rule = one regex + one test (`good first issue`)
125
+ - 🔌 New harness adapter or quarantine emitter = one file + one test
126
+ - 🌱 `unflake init` for your framework = one snippet
127
+ - 🌍 Translations welcome (`README.<lang>.md`)
128
+ - Full loop: [`CONTRIBUTING.md`](CONTRIBUTING.md)
129
+
130
+ ## Roadmap
131
+
132
+ - [x] v0.1 — scan / analyze / quarantine, SARIF, 4 frameworks, skill + adapters
133
+ - [x] v0.2 — `run` collector, regression-vs-flake verdicts, `init` scaffolder, FLK009/FLK010, pre-commit + GitHub Action, `pip install unflake`
134
+ - [x] v0.3 — `--runner` presets, per-run logs, real-pytest e2e in CI, launch kit (`demo.sh`, templates, `LAUNCH.md`), real-repo validation
135
+ - [x] v0.4 — precision (localhost-aware FLK004, literal-awareness), `--exclude`, SARIF rules, safe `-k` quoting, CI matrix 3.10–3.14
136
+ - [x] v0.5 — `unflake-ci` npx wrapper (verified pack→install→run), `--runner vitest` verified e2e
137
+ - [x] v0.6 — `--runner` playwright + jest verified e2e, real-repo study (3,676 tests, 0 false quarantines)
138
+ - [ ] Recall study v2 on suites with known flakes (specificity proven; recall needs wild flakes)
139
+ - [ ] JS/TS string-literal awareness + variable-host local-server detection (see BENCH.md)
140
+
141
+ ## License
142
+
143
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,125 @@
1
+ # Unflake 🟢
2
+
3
+ [![CI](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml/badge.svg)](https://github.com/YOUR-USER/unflake/actions/workflows/ci.yml)
4
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/YOUR-USER/unflake/badge)](https://scorecard.dev/viewer/?uri=github.com/YOUR-USER/unflake)
5
+ [![PyPI](https://img.shields.io/pypi/v/unflake.svg)](https://pypi.org/project/unflake/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
8
+ **Green CI without the "just rerun it" ritual.**
9
+
10
+ ![Unflake 60-second demo](assets/terminal-demo.svg)
11
+
12
+ CI is red. You rerun. It's green. You merge, trusting nothing. Unflake hunts the flake instead: static scan in milliseconds, FlakeScore across repeated runs, regression-vs-flake verdicts, and quarantine configs that keep the build green *without deleting a single test*.
13
+
14
+ ![scan → run → calm](assets/pipeline.svg)
15
+
16
+ ```bash
17
+ pip install unflake
18
+ unflake scan tests/ # 1. smells, instantly
19
+ unflake run --runs 3 --runner pytest -- pytest tests/ -q # 2. collect + score
20
+ unflake quarantine run1.xml run2.xml --framework pytest # 3. quarantine, don't delete
21
+ unflake init --framework pytest --write # 4. seeded RNG scaffold (prevention)
22
+ ```
23
+
24
+ JS-first repo? Same tool, no Python packaging — just Python itself (3.9+):
25
+
26
+ ```bash
27
+ npx unflake-ci scan tests/
28
+ npx unflake-ci run --runs 3 --runner vitest -- npx vitest run tests/
29
+ ```
30
+
31
+ Proven on real suites: 3,676 tests × 5 runs across attrs/click/tqdm → FlakeScore 100,
32
+ 0 false quarantines ([study](benchmarks/BENCH.md)).
33
+
34
+ No install handy? `bash demo.sh` runs the whole loop on fixtures in 60 seconds.
35
+
36
+ > ⭐ If flaky tests have ever paged you, star this — it helps other devs find it.
37
+
38
+ ## Before / after
39
+
40
+ ```python
41
+ # before: the 3am pager
42
+ def test_checkout():
43
+ time.sleep(5) # pray the server is up
44
+ assert requests.get(API).ok # real network in a unit test
45
+ ```
46
+
47
+ ```bash
48
+ $ unflake scan tests/
49
+ WARNING [FLK001] tests/test_shop.py:4 — time.sleep() in tests makes timing-dependent flakes
50
+ fix: Replace with polling (wait_for / waitFor / eventually) with a timeout, or freeze time.
51
+ WARNING [FLK004] tests/test_shop.py:5 — Real network calls in tests fail without mocks ...
52
+ ```
53
+
54
+ ```bash
55
+ $ unflake run --runs 3 -- pytest tests/ -q
56
+ FlakeScore: 50.0/100 (2 flaky of 4 tests)
57
+ FLAKY test_cart::test_checkout (2P/1F over 3 runs)
58
+ FLAKY test_cart::test_discount (2P/1F over 3 runs)
59
+ ```
60
+
61
+ ```bash
62
+ $ unflake quarantine run1.xml run2.xml --framework pytest
63
+ pytest -k "not test_checkout and not test_discount" # gating run stays green
64
+ pytest -k "test_checkout or test_discount" # nightly run still reports
65
+ ```
66
+
67
+ Quarantine preserves signal; deletion hides it. Every quarantined test still runs — it just can't fail the build. **Regressions are never quarantined**: green-then-red-forever is a real failure — fix it.
68
+
69
+ ## Flaky or regression? Unflake knows the difference
70
+
71
+ Pass result files oldest → newest and Unflake separates three fates:
72
+
73
+ | Verdict | Meaning | Action |
74
+ |---|---|---|
75
+ | `FLAKY` | mixed outcomes, no clean break | quarantine + fix root cause |
76
+ | `REGRESSION` | green until run N, red ever since | fix now — never quarantine |
77
+ | `NEW` / stable | seen once / always green / always red | more runs / ship it / real bug |
78
+
79
+ Two runs can only suggest a flake; calling a regression needs ≥3. One run proves nothing — `unflake run` exists so there's no excuse.
80
+
81
+ ## Why not the others?
82
+
83
+ | | Unflake | Heavy flake platforms |
84
+ |---|---|---|
85
+ | Install | zero-dep, stdlib only, `pip install unflake` | OTel collectors, dashboards, SaaS |
86
+ | Input | JUnit XML from **any** framework (pytest ✅, Vitest ✅, Playwright ✅, Jest ✅ e2e — plus generic `--junit-flag`, Go, JUnit…) | per-framework reporters/plugins |
87
+ | First value | `scan` in milliseconds, no execution | needs history ingestion |
88
+ | Verdicts | flaky vs regression vs new, change-point included | usually just a score |
89
+ | Philosophy | quarantine, never delete | often auto-skip-and-forget |
90
+ | Agent-native | `SKILL.md` + harness adapters day one | docs page, if you're lucky |
91
+
92
+ ## Works with your harness
93
+
94
+ Claude Code · Codex · Copilot · Cursor · Gemini · Pi · OpenCode · Windsurf · Cline · Qoder — plus a GitHub Action (`action.yml`, SARIF → code scanning) and a pre-commit hook. Details: [`docs/ADAPTERS.md`](docs/ADAPTERS.md). The skill lives at [`skills/unflake/SKILL.md`](skills/unflake/SKILL.md).
95
+
96
+ 10 rules today, incl. the JS classics: `cy.wait(ms)` (FLK009) and `waitForTimeout` (FLK010).
97
+ Precision features: localhost-aware severities, string-literal awareness (Python),
98
+ `--exclude` globs, and SARIF rule metadata for code scanning.
99
+
100
+ ## Benchmarks (honest, reproducible)
101
+
102
+ Fixtures + real pytest e2e + a real-repo scan (`psf/requests`: 120 findings in ~0.1s — with the noise documented, not hidden). Method, numbers, and known limitations: [`benchmarks/BENCH.md`](benchmarks/BENCH.md). If a number doesn't reproduce, that's a bug — file it.
103
+
104
+ ## Contributing — built for drive-by PRs
105
+
106
+ - 🧪 New FLK rule = one regex + one test (`good first issue`)
107
+ - 🔌 New harness adapter or quarantine emitter = one file + one test
108
+ - 🌱 `unflake init` for your framework = one snippet
109
+ - 🌍 Translations welcome (`README.<lang>.md`)
110
+ - Full loop: [`CONTRIBUTING.md`](CONTRIBUTING.md)
111
+
112
+ ## Roadmap
113
+
114
+ - [x] v0.1 — scan / analyze / quarantine, SARIF, 4 frameworks, skill + adapters
115
+ - [x] v0.2 — `run` collector, regression-vs-flake verdicts, `init` scaffolder, FLK009/FLK010, pre-commit + GitHub Action, `pip install unflake`
116
+ - [x] v0.3 — `--runner` presets, per-run logs, real-pytest e2e in CI, launch kit (`demo.sh`, templates, `LAUNCH.md`), real-repo validation
117
+ - [x] v0.4 — precision (localhost-aware FLK004, literal-awareness), `--exclude`, SARIF rules, safe `-k` quoting, CI matrix 3.10–3.14
118
+ - [x] v0.5 — `unflake-ci` npx wrapper (verified pack→install→run), `--runner vitest` verified e2e
119
+ - [x] v0.6 — `--runner` playwright + jest verified e2e, real-repo study (3,676 tests, 0 false quarantines)
120
+ - [ ] Recall study v2 on suites with known flakes (specificity proven; recall needs wild flakes)
121
+ - [ ] JS/TS string-literal awareness + variable-host local-server detection (see BENCH.md)
122
+
123
+ ## License
124
+
125
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unflake"
7
+ version = "0.6.1"
8
+ description = "Green CI without the rerun ritual — detect, score, and quarantine flaky tests"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["testing", "flaky-tests", "ci", "qa", "agent-skills"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Software Development :: Testing",
19
+ ]
20
+
21
+ [project.scripts]
22
+ unflake = "unflake.cli:main"
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/YOUR-USER/unflake"
26
+ Issues = "https://github.com/YOUR-USER/unflake/issues"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Unflake: detect, score, and quarantine flaky tests. Stdlib only."""
2
+
3
+ __version__ = "0.6.1"
@@ -0,0 +1,14 @@
1
+ """Entry point. Works as `python -m unflake` AND as `python path/to/__main__.py`
2
+ (the latter is how the npx wrapper invokes the vendored core)."""
3
+
4
+ try:
5
+ from .cli import main
6
+ except ImportError: # pragma: no cover - path-invoked fallback
7
+ import os
8
+ import sys
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+ from unflake.cli import main
12
+
13
+ if __name__ == "__main__":
14
+ raise SystemExit(main())
@@ -0,0 +1,214 @@
1
+ """`unflake` CLI. Stdlib only. Exit codes: 0 clean, 1 findings, 2 usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .ingest import load_runs
11
+ from .patterns import SEVERITIES, Finding, scan_path
12
+ from .quarantine import FRAMEWORKS, emit
13
+ from .report import (
14
+ format_analyze_text,
15
+ format_sarif,
16
+ format_scan_json,
17
+ format_scan_text,
18
+ )
19
+ from .runner import collect
20
+ from .scaffold import FRAMEWORKS as SCAFFOLD_FRAMEWORKS
21
+ from .scaffold import init_framework
22
+ from .score import score_runs
23
+
24
+ ORDER = {"error": 0, "warning": 1, "info": 2}
25
+
26
+
27
+ def _meets_threshold(f: Finding, fail_on: str) -> bool:
28
+ return ORDER[f.severity] <= ORDER[fail_on]
29
+
30
+
31
+ def cmd_scan(args: argparse.Namespace) -> int:
32
+ findings: list[Finding] = []
33
+ for target in args.targets:
34
+ try:
35
+ findings.extend(scan_path(target, exclude=args.exclude))
36
+ except ValueError as exc:
37
+ print(f"unflake: error: {exc}", file=sys.stderr)
38
+ return 2
39
+ if args.format == "json":
40
+ print(format_scan_json(findings))
41
+ elif args.format == "sarif":
42
+ print(format_sarif(findings))
43
+ else:
44
+ print(format_scan_text(findings))
45
+ bad = [f for f in findings if _meets_threshold(f, args.fail_on)]
46
+ return 1 if bad else 0
47
+
48
+
49
+ def _print_suite(suite, fmt: str) -> None:
50
+ if fmt == "json":
51
+ print(json.dumps(suite.to_dict(), indent=2))
52
+ elif fmt == "sarif":
53
+ print(format_sarif([], suite))
54
+ else:
55
+ print(format_analyze_text(suite))
56
+
57
+
58
+ def _analyze_gate(suite, fail_on: str) -> int:
59
+ if fail_on == "never":
60
+ return 0
61
+ if fail_on == "flaky":
62
+ return 1 if (suite.flaky_count or suite.regression_count) else 0
63
+ return 0
64
+
65
+
66
+ def _quarantine_note(suite, framework: str | None) -> None:
67
+ if not framework:
68
+ return
69
+ flaky_ids = [t.id for t in suite.tests if t.verdict == "flaky"]
70
+ if not flaky_ids:
71
+ return
72
+ print()
73
+ print(emit(framework, flaky_ids))
74
+
75
+
76
+ def cmd_analyze(args: argparse.Namespace) -> int:
77
+ try:
78
+ runs = load_runs(args.results)
79
+ except ValueError as exc:
80
+ print(f"unflake: error: {exc}", file=sys.stderr)
81
+ return 2
82
+ if len(runs) < 2:
83
+ print("unflake: warning: only 1 run given — flakiness needs >= 2 runs "
84
+ "of the same suite to compare.", file=sys.stderr)
85
+ suite = score_runs(runs)
86
+ _print_suite(suite, args.format)
87
+ return _analyze_gate(suite, args.fail_on)
88
+
89
+
90
+ def cmd_quarantine(args: argparse.Namespace) -> int:
91
+ try:
92
+ runs = load_runs(args.results)
93
+ except ValueError as exc:
94
+ print(f"unflake: error: {exc}", file=sys.stderr)
95
+ return 2
96
+ suite = score_runs(runs)
97
+ regressions = [t.id for t in suite.tests if t.verdict == "regression"]
98
+ if regressions:
99
+ print("# unflake quarantine: NOT quarantining regressions "
100
+ "(real failures — fix them):", file=sys.stderr)
101
+ for rid in regressions:
102
+ print(f"# - {rid}", file=sys.stderr)
103
+ flaky_ids = [t.id for t in suite.tests if t.verdict == "flaky"]
104
+ if not flaky_ids:
105
+ print("# unflake quarantine: no flaky tests — nothing to quarantine.")
106
+ return 0
107
+ try:
108
+ print(emit(args.framework, flaky_ids))
109
+ except ValueError as exc:
110
+ print(f"unflake: error: {exc}", file=sys.stderr)
111
+ return 2
112
+ return 0
113
+
114
+
115
+ def cmd_run(args: argparse.Namespace) -> int:
116
+ if args.runs < 2:
117
+ print("unflake: error: --runs must be >= 2 (one run proves nothing)",
118
+ file=sys.stderr)
119
+ return 2
120
+ cmd = list(args.test_command or [])
121
+ if cmd[:1] == ["--"]:
122
+ cmd = cmd[1:] # argparse.REMAINDER keeps the separator
123
+ if not cmd:
124
+ print("unflake: error: no test command given after `--`", file=sys.stderr)
125
+ return 2
126
+ try:
127
+ files = collect(
128
+ cmd, runs=args.runs, out_dir=args.out_dir,
129
+ junit_flag=args.junit_flag, runner=args.runner, timeout=args.timeout,
130
+ progress=lambda m: print(f"unflake: {m}", file=sys.stderr),
131
+ )
132
+ except ValueError as exc:
133
+ print(f"unflake: error: {exc}", file=sys.stderr)
134
+ return 2
135
+ print(f"unflake: collected {len(files)} run(s) in {args.out_dir}", file=sys.stderr)
136
+ suite = score_runs(load_runs(files))
137
+ _print_suite(suite, args.format)
138
+ _quarantine_note(suite, args.quarantine)
139
+ return _analyze_gate(suite, args.fail_on)
140
+
141
+
142
+ def cmd_init(args: argparse.Namespace) -> int:
143
+ try:
144
+ print(init_framework(args.framework, root=args.root, write=args.write))
145
+ except ValueError as exc:
146
+ print(f"unflake: error: {exc}", file=sys.stderr)
147
+ return 2
148
+ return 0
149
+
150
+
151
+ def build_parser() -> argparse.ArgumentParser:
152
+ p = argparse.ArgumentParser(
153
+ prog="unflake",
154
+ description="Green CI without the rerun ritual — detect, score, quarantine flaky tests.",
155
+ )
156
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
157
+ sub = p.add_subparsers(dest="command", required=True)
158
+
159
+ s = sub.add_parser("scan", help="Static scan for flake anti-patterns (no test execution).")
160
+ s.add_argument("targets", nargs="+", help="File(s) or directorie(s) to scan.")
161
+ s.add_argument("--exclude", action="append", default=[],
162
+ help="Extra path-part glob to skip (repeatable; "
163
+ "defaults already skip .git, node_modules, venvs, build dirs).")
164
+ s.add_argument("--format", choices=["text", "json", "sarif"], default="text")
165
+ s.add_argument("--fail-on", choices=list(SEVERITIES), default="warning",
166
+ help="Exit 1 if any finding at/above this severity (default: warning).")
167
+ s.set_defaults(func=cmd_scan)
168
+
169
+ a = sub.add_parser("analyze", help="Score flakiness across >=2 result files (JUnit XML or JSON).")
170
+ a.add_argument("results", nargs="+", help="Result files from repeated runs, oldest first.")
171
+ a.add_argument("--format", choices=["text", "json", "sarif"], default="text")
172
+ a.add_argument("--fail-on", choices=["flaky", "never"], default="never",
173
+ help="'flaky' exits 1 on flaky tests OR regressions.")
174
+ a.set_defaults(func=cmd_analyze)
175
+
176
+ q = sub.add_parser("quarantine", help="Emit quarantine config for flaky tests (never deletes).")
177
+ q.add_argument("results", nargs="+", help="Result files from repeated runs, oldest first.")
178
+ q.add_argument("--framework", choices=list(FRAMEWORKS), required=True)
179
+ q.set_defaults(func=cmd_quarantine)
180
+
181
+ r = sub.add_parser("run", help="Run your test command N times, then score (collects JUnit when possible).")
182
+ r.add_argument("--runs", type=int, default=3, help="Repeat count, >= 2 (default: 3).")
183
+ r.add_argument("--out-dir", default=".unflake/runs", help="Where per-run files land.")
184
+ r.add_argument("--junit-flag", default=None,
185
+ help="Flag your runner uses for JUnit output, e.g. '--junitxml=' "
186
+ "(auto-detected for pytest; omit for exit-code-only mode).")
187
+ r.add_argument("--runner", default=None,
188
+ help="Verified runner preset (pytest, vitest, playwright, jest). "
189
+ "Others: use --junit-flag.")
190
+ r.add_argument("--timeout", type=float, default=None, help="Per-run timeout in seconds.")
191
+ r.add_argument("--format", choices=["text", "json", "sarif"], default="text")
192
+ r.add_argument("--fail-on", choices=["flaky", "never"], default="flaky")
193
+ r.add_argument("--quarantine", choices=list(FRAMEWORKS), default=None,
194
+ help="Also emit a quarantine snippet for this framework.")
195
+ r.add_argument("test_command", nargs=argparse.REMAINDER,
196
+ help="Test command after `--`, e.g. `-- pytest tests/ -q`.")
197
+ r.set_defaults(func=cmd_run)
198
+
199
+ i = sub.add_parser("init", help="Scaffold seeded-RNG / frozen-time helpers for a framework.")
200
+ i.add_argument("--framework", choices=list(SCAFFOLD_FRAMEWORKS), required=True)
201
+ i.add_argument("--root", default=".", help="Project root to write into.")
202
+ i.add_argument("--write", action="store_true",
203
+ help="Write files (idempotent). Without it, print to stdout.")
204
+ i.set_defaults(func=cmd_init)
205
+ return p
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ args = build_parser().parse_args(argv)
210
+ return int(args.func(args))
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())
@@ -0,0 +1,95 @@
1
+ """Ingest test results from JUnit XML and generic JSON result files.
2
+
3
+ Universal input: anything that can emit JUnit XML works
4
+ (pytest --junitxml, jest-junit, vitest --reporter=junit, Playwright,
5
+ JUnit/Gradle, go-junit-report, ...). No framework SDK required.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import xml.etree.ElementTree as ET
12
+ from pathlib import Path
13
+
14
+ PASS = "passed"
15
+ FAIL = "failed"
16
+ SKIP = "skipped"
17
+
18
+
19
+ def _test_id(classname: str, name: str, filepath: str = "") -> str:
20
+ if classname:
21
+ return f"{classname}::{name}"
22
+ if filepath:
23
+ return f"{filepath}::{name}"
24
+ return name
25
+
26
+
27
+ def parse_junit_xml(path: str | Path) -> dict[str, str]:
28
+ """Parse one JUnit XML file -> {test_id: status}."""
29
+ path = Path(path)
30
+ try:
31
+ tree = ET.parse(path)
32
+ except ET.ParseError as exc:
33
+ raise ValueError(f"{path}: not valid XML ({exc})") from exc
34
+ root = tree.getroot()
35
+ results: dict[str, str] = {}
36
+ suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
37
+ if not suites and root.tag != "testsuite":
38
+ raise ValueError(f"{path}: no <testsuite> found, is this JUnit XML?")
39
+ for suite in suites:
40
+ for case in suite.iter("testcase"):
41
+ name = case.get("name", "unknown")
42
+ classname = case.get("classname", "") or ""
43
+ filepath = case.get("file", "") or ""
44
+ tid = _test_id(classname, name, filepath)
45
+ if case.find("failure") is not None or case.find("error") is not None:
46
+ status = FAIL
47
+ elif case.find("skipped") is not None:
48
+ status = SKIP
49
+ else:
50
+ status = PASS
51
+ results[tid] = status
52
+ return results
53
+
54
+
55
+ def parse_generic_json(path: str | Path) -> dict[str, str]:
56
+ """Parse {"tests": [{"id": ..., "status": "passed|failed|skipped"}]}."""
57
+ path = Path(path)
58
+ try:
59
+ data = json.loads(path.read_text(encoding="utf-8"))
60
+ except json.JSONDecodeError as exc:
61
+ raise ValueError(f"{path}: not valid JSON ({exc})") from exc
62
+ items = data.get("tests", data if isinstance(data, list) else [])
63
+ results: dict[str, str] = {}
64
+ for item in items:
65
+ tid = str(item.get("id", item.get("name", "unknown")))
66
+ status = str(item.get("status", item.get("outcome", PASS))).lower()
67
+ if status in {"failure", "fail", "failed", "error"}:
68
+ status = FAIL
69
+ elif status in {"skip", "skipped", "xskip"}:
70
+ status = SKIP
71
+ else:
72
+ status = PASS
73
+ results[tid] = status
74
+ return results
75
+
76
+
77
+ def parse_file(path: str | Path) -> dict[str, str]:
78
+ """Auto-detect format by extension/content -> {test_id: status}."""
79
+ path = Path(path)
80
+ if not path.exists():
81
+ raise ValueError(f"{path}: file not found")
82
+ if path.suffix.lower() == ".json":
83
+ return parse_generic_json(path)
84
+ head = path.read_bytes()[:2000].lstrip()
85
+ if head.startswith(b"{") or head.startswith(b"["):
86
+ return parse_generic_json(path)
87
+ return parse_junit_xml(path)
88
+
89
+
90
+ def load_runs(paths: list[str | Path]) -> list[dict[str, str]]:
91
+ """Load N result files (e.g. N repeated runs of the same suite)."""
92
+ runs = []
93
+ for p in paths:
94
+ runs.append(parse_file(p))
95
+ return runs