blot-git 0.0.1__tar.gz → 0.2.0__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.
- {blot_git-0.0.1 → blot_git-0.2.0}/CONTRIBUTING.md +1 -1
- {blot_git-0.0.1 → blot_git-0.2.0}/PKG-INFO +5 -2
- {blot_git-0.0.1 → blot_git-0.2.0}/README.md +8 -12
- blot_git-0.2.0/assets/demo/demo.gif +0 -0
- blot_git-0.2.0/assets/demo/make_demo.py +142 -0
- blot_git-0.2.0/bench/README.md +42 -0
- blot_git-0.2.0/bench/RESULTS.md +73 -0
- blot_git-0.2.0/bench/cases.json +83 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/docs/PRD.md +2 -2
- {blot_git-0.0.1 → blot_git-0.2.0}/docs/PYPI.md +3 -1
- {blot_git-0.0.1 → blot_git-0.2.0}/pyproject.toml +3 -2
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/__init__.py +1 -1
- blot_git-0.2.0/src/blot/bench.py +250 -0
- blot_git-0.2.0/src/blot/checks/base.py +61 -0
- blot_git-0.2.0/src/blot/checks/cochange.py +46 -0
- blot_git-0.2.0/src/blot/checks/readded_bug.py +66 -0
- blot_git-0.2.0/src/blot/checks/removed_fix.py +146 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/classify.py +47 -5
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/cli.py +31 -3
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/config.py +13 -3
- blot_git-0.2.0/src/blot/diff.py +147 -0
- blot_git-0.2.0/src/blot/git.py +54 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/hooks.py +1 -1
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/index.py +14 -6
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/store.py +26 -15
- blot_git-0.2.0/tests/test_bench.py +111 -0
- blot_git-0.2.0/tests/test_checks.py +124 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_classify.py +11 -0
- blot_git-0.2.0/tests/test_cli.py +64 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_config.py +17 -4
- blot_git-0.2.0/tests/test_git.py +18 -0
- blot_git-0.0.1/bench/README.md +0 -19
- blot_git-0.0.1/bench/cases.json +0 -17
- blot_git-0.0.1/src/blot/checks/base.py +0 -31
- blot_git-0.0.1/src/blot/checks/cochange.py +0 -24
- blot_git-0.0.1/src/blot/checks/readded_bug.py +0 -23
- blot_git-0.0.1/src/blot/checks/removed_fix.py +0 -22
- blot_git-0.0.1/src/blot/diff.py +0 -68
- blot_git-0.0.1/src/blot/git.py +0 -17
- blot_git-0.0.1/tests/test_cli.py +0 -26
- {blot_git-0.0.1 → blot_git-0.2.0}/.gitattributes +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/pull_request_template.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/workflows/ci.yml +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.github/workflows/release.yml +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.gitignore +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/.pre-commit-hooks.yaml +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/LICENSE +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/alert.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/faces.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/happy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/idle.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/sad.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/sleepy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/walk.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/worried.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/build_sprites.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/alert.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/faces.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/happy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/idle.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/sad.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/sleepy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/walk.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/worried.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/alert.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/faces.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/happy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/idle.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/sad.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/sleepy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/walk.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/worried.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/alert.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/faces.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/happy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/idle.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/sad.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/sleepy.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/walk.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/worried.svg +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/docs/EXPLAINER.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/docs/competitors.md +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/__main__.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/checks/__init__.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/report.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/conftest.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_diff.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_hooks.py +0 -0
- {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_index.py +0 -0
|
@@ -25,7 +25,7 @@ pytest
|
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
## Ground rules
|
|
28
|
-
- Core stays **stdlib-only
|
|
28
|
+
- Core stays **stdlib-only on Python 3.11+** (3.10 gets one small dependency, `tomli`, to read config) and the `check` command stays **under 500 ms**. New runtime dependencies need a strong reason.
|
|
29
29
|
- Quiet beats clever: a new warning must not raise the false-alarm rate on the bench.
|
|
30
30
|
|
|
31
31
|
## Releasing (maintainers)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: blot-git
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Sym-jay/Blot
|
|
6
6
|
Project-URL: Issues, https://github.com/Sym-jay/Blot/issues
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
18
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: tomli>=1.1.0; python_version < '3.11'
|
|
20
21
|
Provides-Extra: dev
|
|
21
22
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
23
|
Provides-Extra: github
|
|
@@ -34,6 +35,8 @@ Blot warns you *before you commit* when your change:
|
|
|
34
35
|
|
|
35
36
|
It runs locally, needs no account or AI, and stays quiet unless the history gives it a real reason to speak.
|
|
36
37
|
|
|
38
|
+

|
|
39
|
+
|
|
37
40
|
```bash
|
|
38
41
|
pip install blot-git
|
|
39
42
|
cd your-repo
|
|
@@ -41,6 +44,6 @@ blot index # one-time, builds .git/blot.db
|
|
|
41
44
|
blot install-hook # adds Blot to your pre-commit hook
|
|
42
45
|
```
|
|
43
46
|
|
|
44
|
-
> **Status:** pre-alpha.
|
|
47
|
+
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
45
48
|
|
|
46
49
|
Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Blot
|
|
2
2
|
|
|
3
|
-
[](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml)
|
|
3
|
+
[](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml) [](https://pypi.org/project/blot-git/)
|
|
4
4
|
|
|
5
5
|
<img src="assets/sora/orange/walk.svg" width="480" alt="Sora, the Blot slime, sliding back and forth">
|
|
6
6
|
|
|
@@ -18,15 +18,11 @@ Blot warns you *before you commit* when your change:
|
|
|
18
18
|
|
|
19
19
|
It runs locally, needs no account or AI, and stays quiet unless the history gives it a real reason to speak.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
! removed-fix src/flask/app.py:355
|
|
25
|
-
You're changing a line added by a bug fix:
|
|
26
|
-
8efea0cc "Break reference cycle created by default in Flask instances" (fixes #3761)
|
|
27
|
-
```
|
|
21
|
+
<img src="assets/demo/demo.gif" width="760" alt="Blot catching a real Flask regression: simplifying self_ref = weakref.ref(self) triggers a removed-fix warning that traces past a 2023 refactor to the 2020 reference-cycle fix">
|
|
22
|
+
|
|
23
|
+
*A real run on Flask. Plain `git blame` points to a 2023 commit that only moved this line; Blot traces past it to the 2020 fix that explains it. (Regenerate with `python assets/demo/make_demo.py`.)*
|
|
28
24
|
|
|
29
|
-
> **Status:** pre-alpha.
|
|
25
|
+
> **Status:** pre-alpha. Milestones M1 and M2 are done: `blot index` builds the history index (Flask's 3,800 commits in ~2 s), `blot install-hook` wires up git, and `blot check` runs the three checks. On Flask it traces an edit to `self_ref = weakref.ref(self)` past the 2023 file split to the 2020 fix, in under 500 ms. Replayed on the last 500 commits of Flask, requests, Express, Django and React, it warns on 0.6–10% of commits; see [bench/RESULTS.md](bench/RESULTS.md). Roadmap: [PRD](docs/PRD.md#12-milestones-4-weeks-part-time).
|
|
30
26
|
|
|
31
27
|
## Meet Sora
|
|
32
28
|
|
|
@@ -50,15 +46,15 @@ Sora comes in four warm palettes:
|
|
|
50
46
|
|
|
51
47
|
## Quickstart
|
|
52
48
|
|
|
53
|
-
Not on PyPI yet, so install from GitHub:
|
|
54
|
-
|
|
55
49
|
```bash
|
|
56
|
-
pip install git
|
|
50
|
+
pip install blot-git # the PyPI name is blot-git; the command is just `blot`
|
|
57
51
|
cd your-repo
|
|
58
52
|
blot index # one-time, builds .git/blot.db (refreshes incrementally after that)
|
|
59
53
|
blot install-hook # adds Blot to .git/hooks/pre-commit without touching existing hooks
|
|
60
54
|
```
|
|
61
55
|
|
|
56
|
+
For the latest unreleased code, use `pip install git+https://github.com/Sym-jay/Blot` instead.
|
|
57
|
+
|
|
62
58
|
Remove it any time with `blot install-hook --uninstall`. Settings go in `[tool.blot]` in `pyproject.toml` or a `.blot.toml` file (see `src/blot/config.py`).
|
|
63
59
|
|
|
64
60
|
Or with the [pre-commit](https://pre-commit.com) framework, see `.pre-commit-hooks.yaml`.
|
|
Binary file
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Render the README / launch-post demo GIF: Blot catching a real Flask regression.
|
|
2
|
+
|
|
3
|
+
Run: pip install pillow && python assets/demo/make_demo.py [path/to/monospace.ttf]
|
|
4
|
+
Out: assets/demo/demo.gif
|
|
5
|
+
|
|
6
|
+
Every line of tool output below was captured from a real run on pallets/flask (d086db85)
|
|
7
|
+
with Blot's pre-commit hook installed. Only the typing animation is synthetic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
18
|
+
|
|
19
|
+
HERE = Path(__file__).parent
|
|
20
|
+
W, H = 1080, 580
|
|
21
|
+
PAD, LINE_H, FONT_SIZE = 28, 26, 18
|
|
22
|
+
VISIBLE_LINES = 20
|
|
23
|
+
COLORS = {
|
|
24
|
+
"bg": "#1F1B16", "bar": "#2A251E", "text": "#EDE3D3", "dim": "#8C8273", "prompt": "#A9BC8E",
|
|
25
|
+
"warn": "#E39A5B", "minus": "#E07A6A", "plus": "#9FCB8A", "sha": "#F4E2AE",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# (kind, text): "cmd" lines are typed, everything else appears at once.
|
|
29
|
+
SCRIPT: list[tuple[str, str]] = [
|
|
30
|
+
("cmd", "pip install blot-git"),
|
|
31
|
+
("dim", "Successfully installed blot-git-0.1.0"),
|
|
32
|
+
("cmd", "blot index"),
|
|
33
|
+
("text", "blot: indexed 3829 commits (103 load-bearing fixes), full, in 2.0s"),
|
|
34
|
+
("cmd", "blot install-hook"),
|
|
35
|
+
("text", "blot: pre-commit hook installed (.git/hooks/pre-commit)"),
|
|
36
|
+
("note", "# that weakref looks over-complicated... let's simplify it"),
|
|
37
|
+
("cmd", "git diff -U0"),
|
|
38
|
+
("dim", "@@ -355 +355 @@ class Flask(App):"),
|
|
39
|
+
("minus", "- self_ref = weakref.ref(self)"),
|
|
40
|
+
("plus", "+ self_ref = self"),
|
|
41
|
+
("cmd", 'git commit -am "Simplify static route"'),
|
|
42
|
+
("warn", "blot: 1 warning"),
|
|
43
|
+
("text", ""),
|
|
44
|
+
("warn", " ! removed-fix src/flask/app.py:355"),
|
|
45
|
+
("text", " You're removing or changing a line that a bug fix added."),
|
|
46
|
+
("text", " Check that the bug it fixed can't come back."),
|
|
47
|
+
("sha", ' 8efea0c "Break reference cycle created by default in Flask instances." (2020-10-03)'),
|
|
48
|
+
("dim", ' traced past refactor 0ec7f71 "Split the App and Blueprint into Sansio and IO parts"'),
|
|
49
|
+
]
|
|
50
|
+
SLEEPY_UNTIL = 3 # Sora dozes while the index builds
|
|
51
|
+
ALERT_FROM = 12 # Sora jumps to alert when the warning prints
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sora():
|
|
55
|
+
spec = importlib.util.spec_from_file_location("build_sprites", HERE.parent / "sora" / "build_sprites.py")
|
|
56
|
+
mod = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(mod)
|
|
58
|
+
return mod
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _font(path: str | None) -> ImageFont.FreeTypeFont:
|
|
62
|
+
candidates = [path] if path else []
|
|
63
|
+
candidates += ["C:/Windows/Fonts/consola.ttf", "/System/Library/Fonts/Menlo.ttc",
|
|
64
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"]
|
|
65
|
+
for c in candidates:
|
|
66
|
+
if c and os.path.exists(c):
|
|
67
|
+
return ImageFont.truetype(c, FONT_SIZE)
|
|
68
|
+
sys.exit("no monospace font found; pass a .ttf path as the first argument")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _draw_sora(draw: ImageDraw.ImageDraw, sora, face: str, x: int, y: int, scale: int = 5) -> None:
|
|
72
|
+
palette = {**sora.PALETTES["orange"], **sora.FIXED}
|
|
73
|
+
cells = {}
|
|
74
|
+
for row_i, row in enumerate(sora.BODY):
|
|
75
|
+
for col_i, key in enumerate(row):
|
|
76
|
+
if key not in ".S":
|
|
77
|
+
cells[(col_i, row_i + sora.TOP)] = key
|
|
78
|
+
for col_i, row_i, key in sora.FACES[face]:
|
|
79
|
+
cells[(col_i, row_i + sora.TOP)] = key
|
|
80
|
+
for (cx, cy), key in cells.items():
|
|
81
|
+
draw.rectangle([x + cx * scale, y + cy * scale, x + cx * scale + scale - 1, y + cy * scale + scale - 1],
|
|
82
|
+
fill=palette[key])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _frame(lines: list[tuple[str, str]], cursor: bool, face: str, font, sora) -> Image.Image:
|
|
86
|
+
img = Image.new("RGB", (W, H), COLORS["bg"])
|
|
87
|
+
d = ImageDraw.Draw(img)
|
|
88
|
+
d.rectangle([0, 0, W, 34], fill=COLORS["bar"])
|
|
89
|
+
for i, c in enumerate(("#E07A6A", "#E8A33B", "#9FCB8A")):
|
|
90
|
+
d.ellipse([16 + i * 22, 11, 28 + i * 22, 23], fill=c)
|
|
91
|
+
d.text((W // 2, 17), "blot demo: pallets/flask", font=font, fill=COLORS["dim"], anchor="mm")
|
|
92
|
+
shown = lines[-VISIBLE_LINES:]
|
|
93
|
+
for i, (kind, text) in enumerate(shown):
|
|
94
|
+
y = 50 + i * LINE_H
|
|
95
|
+
if kind == "cmd":
|
|
96
|
+
d.text((PAD, y), "$", font=font, fill=COLORS["prompt"])
|
|
97
|
+
d.text((PAD + 20, y), text, font=font, fill=COLORS["text"])
|
|
98
|
+
else:
|
|
99
|
+
color = COLORS.get(kind, COLORS["text"]) if kind != "note" else COLORS["dim"]
|
|
100
|
+
d.text((PAD, y), text, font=font, fill=color)
|
|
101
|
+
if cursor:
|
|
102
|
+
last_kind, last_text = shown[-1] if shown else ("cmd", "")
|
|
103
|
+
offset = 20 if last_kind == "cmd" else 0
|
|
104
|
+
cx = PAD + offset + int(font.getlength(last_text))
|
|
105
|
+
cy = 50 + (len(shown) - 1) * LINE_H
|
|
106
|
+
d.rectangle([cx + 2, cy + 2, cx + 11, cy + 20], fill=COLORS["text"])
|
|
107
|
+
_draw_sora(d, sora, face, W - 28 * 5 - 28, 40) # top right: the early lines there are short
|
|
108
|
+
return img
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> None:
|
|
112
|
+
font = _font(sys.argv[1] if len(sys.argv) > 1 else None)
|
|
113
|
+
sora = _sora()
|
|
114
|
+
frames: list[Image.Image] = []
|
|
115
|
+
durations: list[int] = []
|
|
116
|
+
shown: list[tuple[str, str]] = []
|
|
117
|
+
|
|
118
|
+
def add(face: str, ms: int, cursor: bool = True) -> None:
|
|
119
|
+
frames.append(_frame(shown, cursor, face, font, sora))
|
|
120
|
+
durations.append(ms)
|
|
121
|
+
|
|
122
|
+
for step, (kind, text) in enumerate(SCRIPT):
|
|
123
|
+
face = "sleepy" if step < SLEEPY_UNTIL else ("alert" if step >= ALERT_FROM else "idle")
|
|
124
|
+
if kind == "cmd":
|
|
125
|
+
shown.append(("cmd", ""))
|
|
126
|
+
for n in range(0, len(text) + 1, 3):
|
|
127
|
+
shown[-1] = ("cmd", text[:n])
|
|
128
|
+
add(face, 45)
|
|
129
|
+
shown[-1] = ("cmd", text)
|
|
130
|
+
add(face, 450)
|
|
131
|
+
else:
|
|
132
|
+
shown.append((kind, text))
|
|
133
|
+
add(face, 1100 if step == len(SCRIPT) - 1 else 220, cursor=False)
|
|
134
|
+
add("alert", 4500, cursor=False)
|
|
135
|
+
|
|
136
|
+
out = HERE / "demo.gif"
|
|
137
|
+
frames[0].save(out, save_all=True, append_images=frames[1:], duration=durations, loop=0, optimize=True)
|
|
138
|
+
print(f"wrote {out} ({len(frames)} frames, {out.stat().st_size // 1024} KB)")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Bench
|
|
2
|
+
|
|
3
|
+
How we keep Blot's warnings accurate and rare. Two tools, both in `blot bench`.
|
|
4
|
+
|
|
5
|
+
## 1. Cases: known right answers
|
|
6
|
+
|
|
7
|
+
`cases.json` lists real edits on public repos, each labeled with what Blot **should** say.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
blot bench # clones case repos into ~/.cache/blot-bench, prints precision/recall
|
|
11
|
+
blot bench --workdir D:\bench # put the clones somewhere else (short paths help on Windows)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Each case:
|
|
15
|
+
|
|
16
|
+
| Field | Meaning |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `id` | short unique name |
|
|
19
|
+
| `repo`, `at` | public repo URL and the commit to check out |
|
|
20
|
+
| `file` | file to edit |
|
|
21
|
+
| `replace` | `["old text", "new text"]`; the old text must appear **exactly once** in the file |
|
|
22
|
+
| `expect.check` | which check is being tested: `removed-fix`, `readded-bug` or `forgot-related-file` |
|
|
23
|
+
| `expect.warn` | `true` if it should warn, `false` if it must stay quiet |
|
|
24
|
+
| `expect.commit` | (warn cases) the commit the warning must cite; a prefix is fine |
|
|
25
|
+
| `notes` | why: link the PR or issue that explains the code |
|
|
26
|
+
|
|
27
|
+
Add **quiet cases** (`"warn": false`) as often as warning cases. They protect against false alarms.
|
|
28
|
+
|
|
29
|
+
## 2. Replay: how noisy is Blot on real history?
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
cd some-big-repo
|
|
33
|
+
blot bench --replay 500 --json replay.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Replay re-checks the last 500 non-merge commits as if each were being committed at that moment,
|
|
37
|
+
using only the history that existed before it. It prints how many commits would have shown a warning
|
|
38
|
+
and lists every warning, so you can label each one as useful or noise. Noise patterns become fixes in
|
|
39
|
+
`classify.py` or the checks, plus a quiet case here so they never come back.
|
|
40
|
+
|
|
41
|
+
Target: **at most 1 warning per 10 commits**, and most of those should be worth reading.
|
|
42
|
+
See [RESULTS.md](RESULTS.md) for the latest numbers.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Bench results
|
|
2
|
+
|
|
3
|
+
Measured 2026-10-22 with `blot bench`, on Windows 11, Python 3.12.
|
|
4
|
+
|
|
5
|
+
## Cases: 9/9 correct
|
|
6
|
+
|
|
7
|
+
| Check | Cases | Precision | Recall |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| removed-fix | 6 (3 warn, 3 quiet) | 100% | 100% |
|
|
10
|
+
| readded-bug | 2 (2 warn) | 100% | 100% |
|
|
11
|
+
| forgot-related-file | 1 (1 quiet) | n/a | n/a |
|
|
12
|
+
|
|
13
|
+
Nine cases is a start, not a verdict. Each new case is a good first issue (see [README.md](README.md)).
|
|
14
|
+
|
|
15
|
+
## Replay: last 500 non-merge commits of 5 repos
|
|
16
|
+
|
|
17
|
+
Each commit is checked as if it were being committed at that moment, using only the history before
|
|
18
|
+
it. "Before" is Blot 0.1.0's rules; "after" is this release.
|
|
19
|
+
|
|
20
|
+
| Repo | Commits | Before: warnings / 10 commits | After | Commits with a warning (after) | Avg check time |
|
|
21
|
+
|---|---|---|---|---|---|
|
|
22
|
+
| pallets/flask | 500 | 1.96 | **0.10** | 5 (1.0%) | 106 ms |
|
|
23
|
+
| psf/requests | 500 | 1.12 | **0.16** | 3 (0.6%) | 52 ms |
|
|
24
|
+
| expressjs/express | 498 | 1.00 | **0.18** | 7 (1.4%) | 49 ms |
|
|
25
|
+
| django/django | 497 | 26.30 | **1.29** | 50 (10.1%) | 479 ms |
|
|
26
|
+
| facebook/react | 483 | 10.00 | **1.30** | 39 (8.1%) | 821 ms |
|
|
27
|
+
|
|
28
|
+
Target from the PRD: at most 1 warning per 10 commits. Three repos are well under; Django and React are
|
|
29
|
+
slightly over. Check times are in-process (no Python startup) and grow with the size of the change.
|
|
30
|
+
The last rule change (type hints that insert `None`) was re-measured only on requests; it can only
|
|
31
|
+
remove warnings, so the other four numbers are upper bounds.
|
|
32
|
+
|
|
33
|
+
## Were the warnings worth reading?
|
|
34
|
+
|
|
35
|
+
Labeled by hand: every warning for Flask, requests and Express; a sample for Django and React.
|
|
36
|
+
|
|
37
|
+
- **Flask, 5 warnings, 3 useful.** The best: commit `cabda59` changed
|
|
38
|
+
`bp_subdomain is None` to `is not None` on a line from fix `d7b6c1f` (#4834); a later commit had to
|
|
39
|
+
fix `subdomain_matching=False` behavior. Noise: a line from a feature commit, a docstring.
|
|
40
|
+
- **Express, 9 warnings, about 7 useful.** The best: commit `697547cd` **reverted the CVE-2024-51999
|
|
41
|
+
security patch**. Both `removed-fix` and `readded-bug` fired. Others: changes to code from fixes for
|
|
42
|
+
proxy handling (#2252), redirect encoding and charset handling.
|
|
43
|
+
- **requests, 8 warnings, 1 useful** (dropping `chardet` from code added by fix #4102). The rest come
|
|
44
|
+
from one mass reformat and one typing commit, which still slip through in a few lines.
|
|
45
|
+
- **Django, 64 warnings.** Mostly fixes and CVE follow-ups that touch code from earlier fixes, e.g.
|
|
46
|
+
`Fixed #37403 -- Fixed JSONField __in crash on SQLite` flagged for changing code from
|
|
47
|
+
`#37377 -- Fixed JSONField __in crash`. Useful context for a reviewer, though Django's maintainers
|
|
48
|
+
know their history well.
|
|
49
|
+
- **React, 63 warnings.** About half are co-change warnings. Useful when a fix lands in one bundler
|
|
50
|
+
variant (`react-server-dom-webpack`) but not its mirrors (turbopack, parcel); noise for feature-flag
|
|
51
|
+
fork files, which are often edited separately on purpose.
|
|
52
|
+
|
|
53
|
+
## What the replays changed
|
|
54
|
+
|
|
55
|
+
Every noise pattern became a rule and, where possible, a bench case:
|
|
56
|
+
|
|
57
|
+
| Noise | Fix |
|
|
58
|
+
|---|---|
|
|
59
|
+
| "This fixes #N" on features, refactors, docs (old Flask) | feature/refactor/docs subjects count as routine |
|
|
60
|
+
| `Fixed #N -- Added ...` (Django's ticket-first style) | judge the text after `--` |
|
|
61
|
+
| tests, docs, lock files, translations, CI config | skipped by default (`check-tests` / `check-docs` to opt in) |
|
|
62
|
+
| code moved between files | not a removal or a re-add |
|
|
63
|
+
| reformatting and type hints | a line whose tokens survive in order, with nothing logic-changing inserted, is kept |
|
|
64
|
+
| imports, bare keywords, comments, docstrings | ignored |
|
|
65
|
+
| co-change with hub files | the link must hold both ways (≥ 80% each direction) |
|
|
66
|
+
| re-added lines matching fixes in other files | only fixes to the same file count |
|
|
67
|
+
|
|
68
|
+
## Reproduce
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/pallets/flask && cd flask
|
|
72
|
+
blot bench --replay 500 --json replay.json
|
|
73
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "flask-weakref-static-route",
|
|
4
|
+
"repo": "https://github.com/pallets/flask",
|
|
5
|
+
"at": "d086db85",
|
|
6
|
+
"file": "src/flask/app.py",
|
|
7
|
+
"replace": ["self_ref = weakref.ref(self)", "self_ref = self"],
|
|
8
|
+
"expect": {"check": "removed-fix", "warn": true, "commit": "8efea0cc"},
|
|
9
|
+
"notes": "The weakref breaks an app <-> view function reference cycle (#3761). Every git blame variant stops at the refactor 0ec7f713 (Split the App and Blueprint into Sansio and IO parts); the cited commit must be the 2020 fix."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "flask-readd-bound-send-static-file",
|
|
13
|
+
"repo": "https://github.com/pallets/flask",
|
|
14
|
+
"at": "d086db85",
|
|
15
|
+
"file": "src/flask/app.py",
|
|
16
|
+
"replace": ["view_func=lambda **kw: self_ref().send_static_file(**kw), # type: ignore", "view_func=self.send_static_file,"],
|
|
17
|
+
"expect": {"check": "readded-bug", "warn": true, "commit": "8efea0cc"},
|
|
18
|
+
"notes": "Restores the exact line 8efea0cc removed; the bound method is what created the reference cycle."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "flask-quiet-feature-line",
|
|
22
|
+
"repo": "https://github.com/pallets/flask",
|
|
23
|
+
"at": "d086db85",
|
|
24
|
+
"file": "src/flask/app.py",
|
|
25
|
+
"replace": ["setattr(Flask, method.__name__, add_ctx(base_method))", "setattr(Flask, method.__name__, base_method)"],
|
|
26
|
+
"expect": {"check": "removed-fix", "warn": false},
|
|
27
|
+
"notes": "Line comes from 6a649690 (pass context through dispatch methods), a feature commit. Must stay quiet."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "flask-quiet-cochange-app",
|
|
31
|
+
"repo": "https://github.com/pallets/flask",
|
|
32
|
+
"at": "d086db85",
|
|
33
|
+
"file": "src/flask/app.py",
|
|
34
|
+
"replace": ["self_ref = weakref.ref(self)", "self_ref = self"],
|
|
35
|
+
"expect": {"check": "forgot-related-file", "warn": false},
|
|
36
|
+
"notes": "app.py's strongest co-change partner is CHANGES.rst at ~45%, below the 80% threshold. Must stay quiet."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "flask-subdomain-logic-edit",
|
|
40
|
+
"repo": "https://github.com/pallets/flask",
|
|
41
|
+
"at": "d7b6c1f6703df405c69da45e7e0ba3d1aed512ce",
|
|
42
|
+
"file": "src/flask/blueprints.py",
|
|
43
|
+
"replace": ["if state.subdomain is not None and bp_subdomain is None:", "if state.subdomain is not None and bp_subdomain is not None:"],
|
|
44
|
+
"expect": {"check": "removed-fix", "warn": true, "commit": "d7b6c1f"},
|
|
45
|
+
"notes": "Real history: cabda59 changed this line right after fix d7b6c1f (#4834); a later commit had to fix subdomain_matching=False behavior. Same tokens plus `not` is a logic edit, not a reformat."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "express-cve-2024-51999-revert-removed",
|
|
49
|
+
"repo": "https://github.com/expressjs/express",
|
|
50
|
+
"at": "2f64f68c37c64ae333e41ff38032d21860f22255",
|
|
51
|
+
"file": "lib/utils.js",
|
|
52
|
+
"replace": ["plainObjects: true", "allowPrototypes: true"],
|
|
53
|
+
"expect": {"check": "removed-fix", "warn": true, "commit": "2f64f68c"},
|
|
54
|
+
"notes": "Real history: 697547cd reverted the CVE-2024-51999 patch the same day. Found by blot bench --replay."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "express-cve-2024-51999-revert-readded",
|
|
58
|
+
"repo": "https://github.com/expressjs/express",
|
|
59
|
+
"at": "2f64f68c37c64ae333e41ff38032d21860f22255",
|
|
60
|
+
"file": "lib/utils.js",
|
|
61
|
+
"replace": ["plainObjects: true", "allowPrototypes: true"],
|
|
62
|
+
"expect": {"check": "readded-bug", "warn": true, "commit": "2f64f68c"},
|
|
63
|
+
"notes": "Same revert: `allowPrototypes: true` is the exact line the security patch removed."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "requests-quiet-black-quotes",
|
|
67
|
+
"repo": "https://github.com/psf/requests",
|
|
68
|
+
"at": "2d5517682b3b38547634d153cea43d48fbc8cdb5",
|
|
69
|
+
"file": "requests/auth.py",
|
|
70
|
+
"replace": ["if _algorithm == 'MD5' or _algorithm == 'MD5-SESS':", "if _algorithm == \"MD5\" or _algorithm == \"MD5-SESS\":"],
|
|
71
|
+
"expect": {"check": "removed-fix", "warn": false},
|
|
72
|
+
"notes": "Black's quote change from 2a6f290b on a line added by fix 6931bd6. Formatting only: must stay quiet."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "requests-quiet-type-hint",
|
|
76
|
+
"repo": "https://github.com/psf/requests",
|
|
77
|
+
"at": "8f6cda9969f4a98c45ea2922f6bcbefc02256202",
|
|
78
|
+
"file": "src/requests/models.py",
|
|
79
|
+
"replace": [" def links(self):", " def links(self) -> dict[str, dict[str, str]]:"],
|
|
80
|
+
"expect": {"check": "removed-fix", "warn": false},
|
|
81
|
+
"notes": "Adding a type hint (561e4b68) to a method added by fix d8dcd35 (#711). Must stay quiet."
|
|
82
|
+
}
|
|
83
|
+
]
|
|
@@ -35,7 +35,7 @@ These mistakes are caught late (in CI, review or production) or never. AI coding
|
|
|
35
35
|
**Goals (v0.1)**
|
|
36
36
|
- G1. Catch the three mistakes above at commit time, locally, in under 500 ms.
|
|
37
37
|
- G2. Stay **quiet**: on typical repos, at most 1 warning per 10 commits, and at least 80% of warnings judged useful on the bench.
|
|
38
|
-
- G3. Install in one command on Windows, macOS and Linux. Stdlib-only Python.
|
|
38
|
+
- G3. Install in one command on Windows, macOS and Linux. Stdlib-only Python on 3.11+ (3.10 also installs `tomli`).
|
|
39
39
|
- G4. Every warning cites real evidence (commit SHA, and PR or issue when available). Nothing is made up.
|
|
40
40
|
|
|
41
41
|
**Non-goals (v0.1)**
|
|
@@ -130,7 +130,7 @@ The user either reads the linked issue and adjusts the change, or commits anyway
|
|
|
130
130
|
- NFR2. **Quiet:** false-alarm rate on the bench's quiet cases at most 10%.
|
|
131
131
|
- NFR3. **Safety:** never exit non-zero unless `--strict`. Any internal error prints one line and exits 0.
|
|
132
132
|
- NFR4. **Privacy:** no network access except `index --github`. Nothing leaves the machine.
|
|
133
|
-
- NFR5. **Portability:** Python 3.10
|
|
133
|
+
- NFR5. **Portability:** Python 3.10+. No runtime dependencies on 3.11+; on 3.10, `tomli` reads config files (stdlib `tomllib` arrived in 3.11). Windows, macOS and Linux tested in CI.
|
|
134
134
|
|
|
135
135
|
## 8. Architecture
|
|
136
136
|
|
|
@@ -10,6 +10,8 @@ Blot warns you *before you commit* when your change:
|
|
|
10
10
|
|
|
11
11
|
It runs locally, needs no account or AI, and stays quiet unless the history gives it a real reason to speak.
|
|
12
12
|
|
|
13
|
+

|
|
14
|
+
|
|
13
15
|
```bash
|
|
14
16
|
pip install blot-git
|
|
15
17
|
cd your-repo
|
|
@@ -17,6 +19,6 @@ blot index # one-time, builds .git/blot.db
|
|
|
17
19
|
blot install-hook # adds Blot to your pre-commit hook
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
> **Status:** pre-alpha.
|
|
22
|
+
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
21
23
|
|
|
22
24
|
Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "blot-git"
|
|
3
|
-
version = "0.0
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too."
|
|
5
5
|
readme = "docs/PYPI.md" # text-only; the GitHub README's relative Sora images break on PyPI
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -17,7 +17,8 @@ classifiers = [
|
|
|
17
17
|
"Topic :: Software Development :: Quality Assurance",
|
|
18
18
|
"Topic :: Software Development :: Version Control :: Git",
|
|
19
19
|
]
|
|
20
|
-
|
|
20
|
+
# core is stdlib-only: fast hook startup, trivial install. 3.10 has no tomllib, so it gets tomli
|
|
21
|
+
dependencies = ["tomli>=1.1.0; python_version < '3.11'"]
|
|
21
22
|
|
|
22
23
|
[project.urls]
|
|
23
24
|
Homepage = "https://github.com/Sym-jay/Blot"
|