blot-git 0.1.0__tar.gz → 0.3.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 (93) hide show
  1. {blot_git-0.1.0 → blot_git-0.3.0}/PKG-INFO +4 -2
  2. {blot_git-0.1.0 → blot_git-0.3.0}/README.md +29 -1
  3. blot_git-0.3.0/bench/README.md +42 -0
  4. blot_git-0.3.0/bench/RESULTS.md +73 -0
  5. blot_git-0.3.0/bench/cases.json +83 -0
  6. {blot_git-0.1.0 → blot_git-0.3.0}/docs/PRD.md +2 -2
  7. {blot_git-0.1.0 → blot_git-0.3.0}/docs/PYPI.md +3 -1
  8. {blot_git-0.1.0 → blot_git-0.3.0}/pyproject.toml +1 -1
  9. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/__init__.py +1 -1
  10. blot_git-0.3.0/src/blot/bench.py +253 -0
  11. blot_git-0.3.0/src/blot/checks/base.py +65 -0
  12. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/checks/cochange.py +3 -5
  13. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/checks/readded_bug.py +14 -16
  14. blot_git-0.3.0/src/blot/checks/removed_fix.py +62 -0
  15. blot_git-0.3.0/src/blot/classify.py +153 -0
  16. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/cli.py +64 -8
  17. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/config.py +7 -1
  18. blot_git-0.3.0/src/blot/diff.py +147 -0
  19. blot_git-0.3.0/src/blot/github.py +131 -0
  20. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/index.py +13 -8
  21. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/store.py +54 -10
  22. blot_git-0.3.0/src/blot/trace.py +115 -0
  23. blot_git-0.3.0/src/blot/why.py +104 -0
  24. blot_git-0.3.0/tests/test_bench.py +111 -0
  25. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_classify.py +11 -0
  26. blot_git-0.3.0/tests/test_why_github.py +145 -0
  27. blot_git-0.1.0/bench/README.md +0 -19
  28. blot_git-0.1.0/bench/cases.json +0 -17
  29. blot_git-0.1.0/src/blot/checks/base.py +0 -39
  30. blot_git-0.1.0/src/blot/checks/removed_fix.py +0 -140
  31. blot_git-0.1.0/src/blot/classify.py +0 -88
  32. blot_git-0.1.0/src/blot/diff.py +0 -68
  33. {blot_git-0.1.0 → blot_git-0.3.0}/.gitattributes +0 -0
  34. {blot_git-0.1.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  35. {blot_git-0.1.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {blot_git-0.1.0 → blot_git-0.3.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  37. {blot_git-0.1.0 → blot_git-0.3.0}/.github/pull_request_template.md +0 -0
  38. {blot_git-0.1.0 → blot_git-0.3.0}/.github/workflows/ci.yml +0 -0
  39. {blot_git-0.1.0 → blot_git-0.3.0}/.github/workflows/release.yml +0 -0
  40. {blot_git-0.1.0 → blot_git-0.3.0}/.gitignore +0 -0
  41. {blot_git-0.1.0 → blot_git-0.3.0}/.pre-commit-hooks.yaml +0 -0
  42. {blot_git-0.1.0 → blot_git-0.3.0}/CONTRIBUTING.md +0 -0
  43. {blot_git-0.1.0 → blot_git-0.3.0}/LICENSE +0 -0
  44. {blot_git-0.1.0 → blot_git-0.3.0}/assets/demo/demo.gif +0 -0
  45. {blot_git-0.1.0 → blot_git-0.3.0}/assets/demo/make_demo.py +0 -0
  46. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/alert.svg +0 -0
  47. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/faces.svg +0 -0
  48. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/happy.svg +0 -0
  49. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/idle.svg +0 -0
  50. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/sad.svg +0 -0
  51. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/sleepy.svg +0 -0
  52. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/walk.svg +0 -0
  53. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/blue/worried.svg +0 -0
  54. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/build_sprites.py +0 -0
  55. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/alert.svg +0 -0
  56. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/faces.svg +0 -0
  57. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/happy.svg +0 -0
  58. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/idle.svg +0 -0
  59. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/sad.svg +0 -0
  60. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/sleepy.svg +0 -0
  61. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/walk.svg +0 -0
  62. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/mustard/worried.svg +0 -0
  63. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/alert.svg +0 -0
  64. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/faces.svg +0 -0
  65. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/happy.svg +0 -0
  66. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/idle.svg +0 -0
  67. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/sad.svg +0 -0
  68. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/sleepy.svg +0 -0
  69. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/walk.svg +0 -0
  70. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/orange/worried.svg +0 -0
  71. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/alert.svg +0 -0
  72. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/faces.svg +0 -0
  73. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/happy.svg +0 -0
  74. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/idle.svg +0 -0
  75. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/sad.svg +0 -0
  76. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/sleepy.svg +0 -0
  77. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/walk.svg +0 -0
  78. {blot_git-0.1.0 → blot_git-0.3.0}/assets/sora/sage/worried.svg +0 -0
  79. {blot_git-0.1.0 → blot_git-0.3.0}/docs/EXPLAINER.md +0 -0
  80. {blot_git-0.1.0 → blot_git-0.3.0}/docs/competitors.md +0 -0
  81. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/__main__.py +0 -0
  82. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/checks/__init__.py +0 -0
  83. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/git.py +0 -0
  84. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/hooks.py +0 -0
  85. {blot_git-0.1.0 → blot_git-0.3.0}/src/blot/report.py +0 -0
  86. {blot_git-0.1.0 → blot_git-0.3.0}/tests/conftest.py +0 -0
  87. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_checks.py +0 -0
  88. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_cli.py +0 -0
  89. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_config.py +0 -0
  90. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_diff.py +0 -0
  91. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_git.py +0 -0
  92. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_hooks.py +0 -0
  93. {blot_git-0.1.0 → blot_git-0.3.0}/tests/test_index.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blot-git
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -42,8 +42,10 @@ pip install blot-git
42
42
  cd your-repo
43
43
  blot index # one-time, builds .git/blot.db
44
44
  blot install-hook # adds Blot to your pre-commit hook
45
+ blot why src/app.py:42 # which commit explains this line, skipping refactors
46
+ blot index --github # optional: issue/PR titles and labels via the gh CLI
45
47
  ```
46
48
 
47
- > **Status:** pre-alpha. All three checks work; expect rough edges and please report false alarms.
49
+ > **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.
48
50
 
49
51
  Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
@@ -22,7 +22,7 @@ It runs locally, needs no account or AI, and stays quiet unless the history give
22
22
 
23
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`.)*
24
24
 
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. 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).
26
26
 
27
27
  ## Meet Sora
28
28
 
@@ -59,6 +59,34 @@ Remove it any time with `blot install-hook --uninstall`. Settings go in `[tool.b
59
59
 
60
60
  Or with the [pre-commit](https://pre-commit.com) framework, see `.pre-commit-hooks.yaml`.
61
61
 
62
+ ## Ask why a line exists
63
+
64
+ ```
65
+ $ blot why src/flask/app.py:355
66
+ src/flask/app.py:355
67
+ | self_ref = weakref.ref(self)
68
+ explained by 8efea0cc 2020-10-03 Break reference cycle created by default in Flask instances. [bug fix]
69
+ traced past 0ec7f713 2023-06-11 Split the App and Blueprint into Sansio and IO parts [refactor]
70
+ ```
71
+
72
+ `blot why FILE:LINE` (or `FILE:START-END`) finds the commit that explains a line, skipping commits that only moved it. Plain `git blame` stops at the refactor.
73
+
74
+ ## Issue and PR titles (optional)
75
+
76
+ ```bash
77
+ blot index --github
78
+ ```
79
+
80
+ With the [GitHub CLI](https://cli.github.com) logged in, Blot fetches the titles and labels of the issues and PRs your fix commits mention, so warnings read `fixes #2935 "copy_current_request_context copies old session object"` (a real Flask issue) instead of a bare number. A `bug` label makes a commit count as a load-bearing fix; a fix-looking commit whose issues are only labelled `enhancement` or `documentation` is demoted. Refreshes only fetch what's new. Repos with GitHub Issues turned off (they track bugs elsewhere) are skipped.
81
+
82
+ ## Measure it on your repo
83
+
84
+ ```bash
85
+ blot bench --replay 500
86
+ ```
87
+
88
+ Re-checks your last 500 commits as if each were being committed, and lists every warning Blot would have shown. See [bench/README.md](bench/README.md).
89
+
62
90
  ## Docs
63
91
 
64
92
  - [What is this? (simple explanation)](docs/EXPLAINER.md)
@@ -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
+ ]
@@ -190,8 +190,8 @@ The user either reads the linked issue and adjusts the change, or commits anyway
190
190
  |---|---|---|
191
191
  | 1 | **M1: index + config + hook installer** | `blot index` builds the DB on Flask in under 10 s; `install-hook` is safe with existing hooks |
192
192
  | 2 | **M2: three checks** | All unit tests pass, including the "trace past refactor" test (currently skipped) |
193
- | 3 | **M3: `why` + `--github` + output polish** | The Flask case prints the 2020 fix with the #3761 link |
194
- | 4 | **M4: bench + launch** | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
193
+ | 3 | **M3: `why` + `--github` + output polish** ✅ | The Flask case prints the 2020 fix, traced past the 2023 refactor; `--github` adds issue titles and labels |
194
+ | 4 | **M4: bench + launch** (bench ✅, launch pending) | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
195
195
 
196
196
  ## 13. Open questions
197
197
 
@@ -17,8 +17,10 @@ pip install blot-git
17
17
  cd your-repo
18
18
  blot index # one-time, builds .git/blot.db
19
19
  blot install-hook # adds Blot to your pre-commit hook
20
+ blot why src/app.py:42 # which commit explains this line, skipping refactors
21
+ blot index --github # optional: issue/PR titles and labels via the gh CLI
20
22
  ```
21
23
 
22
- > **Status:** pre-alpha. All three checks work; expect rough edges and please report false alarms.
24
+ > **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.
23
25
 
24
26
  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.1.0"
3
+ version = "0.3.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" }
@@ -1,3 +1,3 @@
1
1
  """blot: history-aware pre-commit checks."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,253 @@
1
+ """`blot bench`: measure how accurate Blot is.
2
+
3
+ Two modes:
4
+ cases Run bench/cases.json: scripted edits on public repos with a known right answer
5
+ ("this edit must warn and cite commit X" / "this edit must stay quiet"), and report
6
+ precision and recall per check.
7
+ replay Re-check the last N commits of a repo as if each one were being committed right then,
8
+ to count how often Blot would have spoken up and to collect warnings to label by hand.
9
+
10
+ Replay never lets a commit see its own future. Commit facts (is it a fix? a refactor?) come from a
11
+ full index, which is safe because checks only ever ask about the commit's ancestors. Lines removed
12
+ by fixes only count if that fix is an ancestor of the commit being replayed, and co-change counts
13
+ come from a second index built just before the replay window.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ import time
25
+ from dataclasses import asdict, dataclass, field
26
+
27
+ from blot.checks import REGISTRY, all_checks
28
+ from blot.checks.base import Finding
29
+ from blot.config import Config
30
+ from blot.diff import parse_unified_diff
31
+ from blot.git import run_git
32
+ from blot.index import build_index
33
+ from blot.store import CommitRow, Store
34
+
35
+ DEFAULT_WORKDIR = os.path.join(os.path.expanduser("~"), ".cache", "blot-bench")
36
+
37
+
38
+ # --- replay -----------------------------------------------------------------------------
39
+
40
+
41
+ class _ReplayStore:
42
+ """Looks like a Store to the checks, but hides anything newer than the commit being replayed."""
43
+
44
+ def __init__(self, repo: str, full: Store, early: Store):
45
+ self.repo = repo
46
+ self.rev = "HEAD"
47
+ self._full, self._early = full, early
48
+ self._ancestor: dict[tuple[str, str], bool] = {}
49
+
50
+ def get_commit(self, sha: str) -> CommitRow | None:
51
+ return self._full.get_commit(sha)
52
+
53
+ def describe_issue(self, ref: str) -> str:
54
+ return self._full.describe_issue(ref)
55
+
56
+ def fixes_that_removed(self, line_hash: str) -> list[tuple[str, str]]:
57
+ return [(p, s) for p, s in self._full.fixes_that_removed(line_hash) if self._is_ancestor(s)]
58
+
59
+ def partners(self, path: str, min_support: int, min_confidence: float) -> list[tuple[str, int, float]]:
60
+ return self._early.partners(path, min_support, min_confidence)
61
+
62
+ def _is_ancestor(self, sha: str) -> bool:
63
+ key = (sha, self.rev)
64
+ if key not in self._ancestor:
65
+ done = subprocess.run(["git", "-C", self.repo, "merge-base", "--is-ancestor", sha, self.rev],
66
+ capture_output=True)
67
+ self._ancestor[key] = done.returncode == 0
68
+ return self._ancestor[key]
69
+
70
+
71
+ @dataclass
72
+ class ReplayCommit:
73
+ sha: str
74
+ subject: str
75
+ findings: list[dict]
76
+ ms: int
77
+
78
+
79
+ @dataclass
80
+ class ReplayResult:
81
+ repo: str
82
+ head: str
83
+ checked: int
84
+ skipped_large: int
85
+ seconds: float
86
+ commits: list[ReplayCommit] = field(default_factory=list)
87
+
88
+ @property
89
+ def warned(self) -> list[ReplayCommit]:
90
+ return [c for c in self.commits if c.findings]
91
+
92
+ def by_check(self) -> dict[str, int]:
93
+ counts = {cls.id: 0 for cls in REGISTRY}
94
+ for c in self.commits:
95
+ for f in c.findings:
96
+ counts[f["check"]] = counts.get(f["check"], 0) + 1
97
+ return counts
98
+
99
+
100
+ def replay(repo: str, count: int, config: Config, max_files: int = 50, progress=None) -> ReplayResult:
101
+ start = time.perf_counter()
102
+ head = run_git(repo, "rev-parse", "HEAD").strip()
103
+ commits = run_git(repo, "rev-list", "--no-merges", f"--max-count={count}", "HEAD").split()[::-1]
104
+ tmp = tempfile.mkdtemp(prefix="blot-replay-")
105
+ try:
106
+ build_index(repo, full=True, config=config, db_path=os.path.join(tmp, "full.db"))
107
+ oldest_parent = _parent(repo, commits[0]) if commits else None
108
+ early_db = os.path.join(tmp, "early.db")
109
+ build_index(repo, full=True, config=config, upto=oldest_parent or commits[0], db_path=early_db)
110
+ full, early = Store.open(repo, os.path.join(tmp, "full.db")), Store.open(repo, early_db)
111
+ store = _ReplayStore(repo, full, early)
112
+ checks = all_checks(config)
113
+ result = ReplayResult(repo, head, 0, 0, 0.0)
114
+ for i, sha in enumerate(commits, 1):
115
+ parent = _parent(repo, sha)
116
+ if parent is None:
117
+ continue
118
+ diff = run_git(repo, "diff", "-U0", "--no-color", "--no-ext-diff", "-M", parent, sha)
119
+ changes = parse_unified_diff(diff)
120
+ if len(changes) > max_files:
121
+ result.skipped_large += 1
122
+ continue
123
+ store.rev = parent
124
+ t = time.perf_counter()
125
+ findings: list[Finding] = [f for check in checks for f in check.run(changes, store, config)]
126
+ ms = int((time.perf_counter() - t) * 1000)
127
+ subject = run_git(repo, "log", "-1", "--format=%s", sha).strip()
128
+ result.commits.append(ReplayCommit(sha, subject, [asdict(f) for f in findings], ms))
129
+ result.checked += 1
130
+ if progress:
131
+ progress(i, len(commits))
132
+ full.close()
133
+ early.close()
134
+ result.seconds = time.perf_counter() - start
135
+ return result
136
+ finally:
137
+ shutil.rmtree(tmp, ignore_errors=True)
138
+
139
+
140
+ def _parent(repo: str, sha: str) -> str | None:
141
+ parts = run_git(repo, "rev-list", "--parents", "-n", "1", sha).split()
142
+ return parts[1] if len(parts) > 1 else None
143
+
144
+
145
+ def format_replay(result: ReplayResult) -> str:
146
+ per10 = 10 * sum(len(c.findings) for c in result.commits) / max(result.checked, 1)
147
+ lines = [
148
+ f"blot bench --replay: {result.checked} commits checked in {result.seconds:.0f}s "
149
+ f"({result.skipped_large} skipped as too large)",
150
+ f" commits with a warning: {len(result.warned)} ({100 * len(result.warned) / max(result.checked, 1):.1f}%)",
151
+ f" warnings per 10 commits: {per10:.2f}",
152
+ " by check: " + ", ".join(f"{k} {v}" for k, v in result.by_check().items()),
153
+ ]
154
+ for c in result.warned:
155
+ lines.append(f"\n {c.sha[:9]} {c.subject}")
156
+ for f in c.findings:
157
+ where = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
158
+ lines.append(f" ! {f['check']} {where}")
159
+ lines.extend(f" {e}" for e in f["evidence"])
160
+ return "\n".join(lines) + "\n"
161
+
162
+
163
+ # --- cases ------------------------------------------------------------------------------
164
+
165
+
166
+ @dataclass
167
+ class CaseResult:
168
+ id: str
169
+ check: str
170
+ expected_warn: bool
171
+ warned: bool
172
+ cited_right_commit: bool
173
+ detail: str = ""
174
+
175
+ @property
176
+ def correct(self) -> bool:
177
+ return self.cited_right_commit if self.expected_warn else not self.warned
178
+
179
+
180
+ def _repo_dir(url: str, workdir: str) -> str:
181
+ # always a fresh clone under workdir: the bench checks out old commits and edits files,
182
+ # so it must never run inside a repo the user is working in (even if `repo` is a local path)
183
+ name = re.split(r"[\\/]", url.rstrip("/\\"))[-1].removesuffix(".git") or "repo"
184
+ path = os.path.join(workdir, name)
185
+ os.makedirs(workdir, exist_ok=True)
186
+ if not os.path.isdir(os.path.join(path, ".git")):
187
+ subprocess.run(["git", "clone", "-q", url, path], check=True)
188
+ return path
189
+
190
+
191
+ def run_case(case: dict, workdir: str, config: Config) -> CaseResult:
192
+ expect = case["expect"]
193
+ repo = _repo_dir(case["repo"], workdir)
194
+ at = case["at"]
195
+ if subprocess.run(["git", "-C", repo, "cat-file", "-e", f"{at}^{{commit}}"], capture_output=True).returncode:
196
+ subprocess.run(["git", "-C", repo, "fetch", "-q", "origin"], check=True)
197
+ run_git(repo, "checkout", "-q", "--detach", "--force", at)
198
+ db = os.path.join(workdir, f"{os.path.basename(repo)}-{at[:12]}.db")
199
+ build_index(repo, config=config, upto=at, db_path=db)
200
+
201
+ path, (old, new) = case["file"], case["replace"]
202
+ file = os.path.join(repo, path)
203
+ with open(file, encoding="utf-8", newline="") as f:
204
+ text = f.read()
205
+ if text.count(old) != 1:
206
+ return CaseResult(case["id"], expect["check"], expect["warn"], False, False,
207
+ f"edit text found {text.count(old)} times in {path} (needs exactly 1)")
208
+ try:
209
+ with open(file, "w", encoding="utf-8", newline="") as f:
210
+ f.write(text.replace(old, new))
211
+ changes = parse_unified_diff(run_git(repo, "diff", "-U0", "--no-color", "--no-ext-diff", "--", path))
212
+ store = Store.open(repo, db)
213
+ store.rev = at
214
+ check = next(cls() for cls in REGISTRY if cls.id == expect["check"])
215
+ findings = check.run(changes, store, config)
216
+ store.close()
217
+ finally:
218
+ run_git(repo, "checkout", "-q", "--", path)
219
+
220
+ want = expect.get("commit", "")
221
+ cited = bool(findings) and any(e.startswith(want[:7]) for f in findings for e in f.evidence) if want else bool(findings)
222
+ detail = "; ".join(e for f in findings for e in f.evidence[:1]) or "no warning"
223
+ return CaseResult(case["id"], expect["check"], expect["warn"], bool(findings), cited, detail)
224
+
225
+
226
+ def run_cases(cases_path: str, workdir: str, config: Config) -> list[CaseResult]:
227
+ with open(cases_path, encoding="utf-8") as f:
228
+ cases = json.load(f)
229
+ return [run_case(case, workdir, config) for case in cases]
230
+
231
+
232
+ def format_cases(results: list[CaseResult]) -> str:
233
+ lines = []
234
+ for r in results:
235
+ mark = "ok " if r.correct else "FAIL"
236
+ want = "warn" if r.expected_warn else "quiet"
237
+ lines.append(f" {mark} {r.id} [{r.check}, expect {want}] {r.detail}")
238
+ lines.append("")
239
+ for cls in REGISTRY:
240
+ rs = [r for r in results if r.check == cls.id]
241
+ if not rs:
242
+ continue
243
+ tp = sum(r.expected_warn and r.cited_right_commit for r in rs)
244
+ fn = sum(r.expected_warn and not r.cited_right_commit for r in rs)
245
+ fp = sum(not r.expected_warn and r.warned for r in rs)
246
+ precision = tp / (tp + fp) if tp + fp else None
247
+ recall = tp / (tp + fn) if tp + fn else None
248
+ fmt = lambda v: "n/a" if v is None else f"{v:.0%}" # noqa: E731
249
+ lines.append(f" {cls.id}: {len(rs)} cases, precision {fmt(precision)}, recall {fmt(recall)} "
250
+ f"(tp {tp}, fp {fp}, fn {fn})")
251
+ correct = sum(r.correct for r in results)
252
+ lines.append(f"blot bench: {correct}/{len(results)} cases correct")
253
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,65 @@
1
+ """The interface every check implements. Adding a check = one new file + one registry line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from fnmatch import fnmatch
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from blot.config import Config
12
+ from blot.diff import FileChange
13
+ from blot.store import CommitRow, Store
14
+
15
+
16
+ @dataclass
17
+ class Finding:
18
+ check: str # check id, e.g. "removed-fix"
19
+ path: str
20
+ line: int | None # None for file-level findings (forgot-related-file)
21
+ message: str # one or two plain-English sentences
22
+ evidence: list[str] = field(default_factory=list) # 'abc1234 "subject" (date, fixes #12)'
23
+ severity: int = 2
24
+
25
+
26
+ class Check(ABC):
27
+ id: str = ""
28
+ description: str = ""
29
+
30
+ @abstractmethod
31
+ def run(self, changes: list[FileChange], store: Store, config: Config) -> list[Finding]:
32
+ """Return findings for the staged changes. Must not call the network; target < 100 ms."""
33
+
34
+
35
+ TEST_PATTERNS = ("test/*", "tests/*", "*/test/*", "*/tests/*", "*/__tests__/*", "__tests__/*",
36
+ "test_*", "*/test_*", "*_test.*", "*.test.*", "*.spec.*", "*/testing/*", "conftest.py", "*/conftest.py")
37
+ DOC_PATTERNS = ("doc/*", "docs/*", "*/docs/*", "*.md", "*.rst", "*.txt", "*.adoc", "*.markdown")
38
+
39
+
40
+ # generated files: their lines come from tools, not from the fix's author
41
+ GENERATED_PATTERNS = ("*.lock", "*package-lock.json", "*pnpm-lock.yaml", "*npm-shrinkwrap.json", "*go.sum",
42
+ "*.min.js", "*.min.css", "*.map", "*.mo", "*.po", "*/locale/*", # translations
43
+ ".github/*", ".circleci/*", ".gitlab-ci.yml") # CI configuration
44
+
45
+
46
+ def is_skipped(path: str, config: Config) -> bool:
47
+ """Paths the checks ignore: generated files, config.ignore_paths, and tests and docs unless opted in."""
48
+ patterns = [*GENERATED_PATTERNS, *config.ignore_paths]
49
+ if not config.check_tests:
50
+ patterns += TEST_PATTERNS
51
+ if not config.check_docs:
52
+ patterns += DOC_PATTERNS
53
+ return any(fnmatch(path, p) for p in patterns)
54
+
55
+
56
+ def describe_commit(row: CommitRow, store: Store | None = None) -> str:
57
+ """Evidence line: '8efea0c "subject" (2020-10-03, fixes #3761 "Memory leak in app")'.
58
+
59
+ Issue titles appear once `blot index --github` has fetched them.
60
+ """
61
+ details = [row.date[:10]]
62
+ if row.issues:
63
+ refs = [store.describe_issue(ref) for ref in row.issues] if store else row.issues
64
+ details.append("fixes " + ", ".join(refs))
65
+ return f'{row.sha[:7]} "{row.subject}" ({", ".join(details)})'
@@ -12,9 +12,7 @@ Commits touching more than config.cochange_max_files_per_commit files are ignore
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from fnmatch import fnmatch
16
-
17
- from blot.checks.base import Check, Finding
15
+ from blot.checks.base import Check, Finding, is_skipped
18
16
  from blot.git import tracked_files
19
17
 
20
18
 
@@ -23,7 +21,7 @@ class ForgotRelatedFile(Check):
23
21
  description = "Changed a file without the file that usually changes with it"
24
22
 
25
23
  def run(self, changes, store, config) -> list[Finding]:
26
- ignored = lambda p: any(fnmatch(p, pattern) for pattern in config.ignore_paths) # noqa: E731
24
+ ignored = lambda p: is_skipped(p, config) # noqa: E731
27
25
  staged = {p for c in changes for p in (c.path, c.old_path) if p}
28
26
  head_files: set[str] | None = None
29
27
  findings: list[Finding] = []
@@ -34,7 +32,7 @@ class ForgotRelatedFile(Check):
34
32
  if not partners and change.old_path and change.old_path != change.path:
35
33
  partners = store.partners(change.old_path, config.cochange_min_support, config.cochange_min_confidence)
36
34
  if partners and head_files is None:
37
- head_files = tracked_files(store.repo)
35
+ head_files = tracked_files(store.repo, store.rev)
38
36
  for partner, support, confidence in partners: # strongest first
39
37
  if partner in staged or partner not in head_files or ignored(partner):
40
38
  continue