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.
Files changed (92) hide show
  1. {blot_git-0.0.1 → blot_git-0.2.0}/CONTRIBUTING.md +1 -1
  2. {blot_git-0.0.1 → blot_git-0.2.0}/PKG-INFO +5 -2
  3. {blot_git-0.0.1 → blot_git-0.2.0}/README.md +8 -12
  4. blot_git-0.2.0/assets/demo/demo.gif +0 -0
  5. blot_git-0.2.0/assets/demo/make_demo.py +142 -0
  6. blot_git-0.2.0/bench/README.md +42 -0
  7. blot_git-0.2.0/bench/RESULTS.md +73 -0
  8. blot_git-0.2.0/bench/cases.json +83 -0
  9. {blot_git-0.0.1 → blot_git-0.2.0}/docs/PRD.md +2 -2
  10. {blot_git-0.0.1 → blot_git-0.2.0}/docs/PYPI.md +3 -1
  11. {blot_git-0.0.1 → blot_git-0.2.0}/pyproject.toml +3 -2
  12. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/__init__.py +1 -1
  13. blot_git-0.2.0/src/blot/bench.py +250 -0
  14. blot_git-0.2.0/src/blot/checks/base.py +61 -0
  15. blot_git-0.2.0/src/blot/checks/cochange.py +46 -0
  16. blot_git-0.2.0/src/blot/checks/readded_bug.py +66 -0
  17. blot_git-0.2.0/src/blot/checks/removed_fix.py +146 -0
  18. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/classify.py +47 -5
  19. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/cli.py +31 -3
  20. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/config.py +13 -3
  21. blot_git-0.2.0/src/blot/diff.py +147 -0
  22. blot_git-0.2.0/src/blot/git.py +54 -0
  23. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/hooks.py +1 -1
  24. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/index.py +14 -6
  25. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/store.py +26 -15
  26. blot_git-0.2.0/tests/test_bench.py +111 -0
  27. blot_git-0.2.0/tests/test_checks.py +124 -0
  28. {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_classify.py +11 -0
  29. blot_git-0.2.0/tests/test_cli.py +64 -0
  30. {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_config.py +17 -4
  31. blot_git-0.2.0/tests/test_git.py +18 -0
  32. blot_git-0.0.1/bench/README.md +0 -19
  33. blot_git-0.0.1/bench/cases.json +0 -17
  34. blot_git-0.0.1/src/blot/checks/base.py +0 -31
  35. blot_git-0.0.1/src/blot/checks/cochange.py +0 -24
  36. blot_git-0.0.1/src/blot/checks/readded_bug.py +0 -23
  37. blot_git-0.0.1/src/blot/checks/removed_fix.py +0 -22
  38. blot_git-0.0.1/src/blot/diff.py +0 -68
  39. blot_git-0.0.1/src/blot/git.py +0 -17
  40. blot_git-0.0.1/tests/test_cli.py +0 -26
  41. {blot_git-0.0.1 → blot_git-0.2.0}/.gitattributes +0 -0
  42. {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  43. {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  44. {blot_git-0.0.1 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  45. {blot_git-0.0.1 → blot_git-0.2.0}/.github/pull_request_template.md +0 -0
  46. {blot_git-0.0.1 → blot_git-0.2.0}/.github/workflows/ci.yml +0 -0
  47. {blot_git-0.0.1 → blot_git-0.2.0}/.github/workflows/release.yml +0 -0
  48. {blot_git-0.0.1 → blot_git-0.2.0}/.gitignore +0 -0
  49. {blot_git-0.0.1 → blot_git-0.2.0}/.pre-commit-hooks.yaml +0 -0
  50. {blot_git-0.0.1 → blot_git-0.2.0}/LICENSE +0 -0
  51. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/alert.svg +0 -0
  52. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/faces.svg +0 -0
  53. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/happy.svg +0 -0
  54. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/idle.svg +0 -0
  55. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/sad.svg +0 -0
  56. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/sleepy.svg +0 -0
  57. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/walk.svg +0 -0
  58. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/blue/worried.svg +0 -0
  59. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/build_sprites.py +0 -0
  60. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/alert.svg +0 -0
  61. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/faces.svg +0 -0
  62. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/happy.svg +0 -0
  63. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/idle.svg +0 -0
  64. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/sad.svg +0 -0
  65. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/sleepy.svg +0 -0
  66. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/walk.svg +0 -0
  67. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/mustard/worried.svg +0 -0
  68. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/alert.svg +0 -0
  69. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/faces.svg +0 -0
  70. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/happy.svg +0 -0
  71. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/idle.svg +0 -0
  72. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/sad.svg +0 -0
  73. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/sleepy.svg +0 -0
  74. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/walk.svg +0 -0
  75. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/orange/worried.svg +0 -0
  76. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/alert.svg +0 -0
  77. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/faces.svg +0 -0
  78. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/happy.svg +0 -0
  79. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/idle.svg +0 -0
  80. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/sad.svg +0 -0
  81. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/sleepy.svg +0 -0
  82. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/walk.svg +0 -0
  83. {blot_git-0.0.1 → blot_git-0.2.0}/assets/sora/sage/worried.svg +0 -0
  84. {blot_git-0.0.1 → blot_git-0.2.0}/docs/EXPLAINER.md +0 -0
  85. {blot_git-0.0.1 → blot_git-0.2.0}/docs/competitors.md +0 -0
  86. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/__main__.py +0 -0
  87. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/checks/__init__.py +0 -0
  88. {blot_git-0.0.1 → blot_git-0.2.0}/src/blot/report.py +0 -0
  89. {blot_git-0.0.1 → blot_git-0.2.0}/tests/conftest.py +0 -0
  90. {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_diff.py +0 -0
  91. {blot_git-0.0.1 → blot_git-0.2.0}/tests/test_hooks.py +0 -0
  92. {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** and the `check` command stays **under 500 ms**.
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.1
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
+ ![Blot catching a real Flask regression](https://raw.githubusercontent.com/Sym-jay/Blot/main/assets/demo/demo.gif)
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. Indexing and the git hook work; the three checks arrive in the next milestone.
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
- [![CI](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml/badge.svg)](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml/badge.svg)](https://github.com/Sym-jay/Blot/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/blot-git)](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
- $ git commit -m "simplify static route"
23
- blot: 1 warning
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. Milestone M1 is done: `blot index` builds the history index (Flask's 3,800 commits in ~2 s) and `blot install-hook` wires up git. The three checks land in M2, so `blot check` doesn't warn yet. See the [roadmap in the PRD](docs/PRD.md#12-milestones-4-weeks-part-time).
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+https://github.com/Sym-jay/Blot
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+, stdlib only. Windows, macOS and Linux tested in CI.
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
+ ![Blot catching a real Flask regression](https://raw.githubusercontent.com/Sym-jay/Blot/main/assets/demo/demo.gif)
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. Indexing and the git hook work; the three checks arrive in the next milestone.
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.1"
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
- dependencies = [] # core is stdlib-only: fast hook startup, trivial install
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"
@@ -1,3 +1,3 @@
1
1
  """blot: history-aware pre-commit checks."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.2.0"