blot-git 0.6.0__tar.gz → 0.7.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 (103) hide show
  1. {blot_git-0.6.0 → blot_git-0.7.0}/.pre-commit-hooks.yaml +1 -1
  2. {blot_git-0.6.0 → blot_git-0.7.0}/CLAUDE.md +23 -16
  3. {blot_git-0.6.0 → blot_git-0.7.0}/PKG-INFO +7 -1
  4. {blot_git-0.6.0 → blot_git-0.7.0}/README.md +56 -1
  5. {blot_git-0.6.0 → blot_git-0.7.0}/action.yml +1 -1
  6. {blot_git-0.6.0 → blot_git-0.7.0}/bench/RESULTS.md +19 -4
  7. {blot_git-0.6.0 → blot_git-0.7.0}/bench/cases.json +15 -0
  8. {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/django-django.json +2 -2
  9. {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/pallets-flask.json +2 -2
  10. {blot_git-0.6.0 → blot_git-0.7.0}/docs/PRD.md +16 -2
  11. {blot_git-0.6.0 → blot_git-0.7.0}/docs/PYPI.md +6 -0
  12. {blot_git-0.6.0 → blot_git-0.7.0}/pyproject.toml +1 -1
  13. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/__init__.py +1 -1
  14. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/base.py +10 -0
  15. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/removed_fix.py +35 -11
  16. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/cli.py +65 -12
  17. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/diff.py +25 -0
  18. blot_git-0.7.0/src/blot/fixmap.py +135 -0
  19. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/hooks.py +101 -0
  20. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/index.py +16 -0
  21. blot_git-0.7.0/src/blot/mcp.py +383 -0
  22. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/trace.py +60 -23
  23. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/why.py +28 -4
  24. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_checks.py +143 -0
  25. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_cli.py +28 -0
  26. blot_git-0.7.0/tests/test_diff.py +80 -0
  27. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_guard.py +59 -0
  28. blot_git-0.7.0/tests/test_map.py +101 -0
  29. blot_git-0.7.0/tests/test_mcp.py +199 -0
  30. blot_git-0.6.0/tests/test_diff.py +0 -39
  31. {blot_git-0.6.0 → blot_git-0.7.0}/.gitattributes +0 -0
  32. {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
  33. {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  34. {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
  35. {blot_git-0.6.0 → blot_git-0.7.0}/.github/pull_request_template.md +0 -0
  36. {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/blot.yml +0 -0
  37. {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/ci.yml +0 -0
  38. {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/release.yml +0 -0
  39. {blot_git-0.6.0 → blot_git-0.7.0}/.gitignore +0 -0
  40. {blot_git-0.6.0 → blot_git-0.7.0}/CONTRIBUTING.md +0 -0
  41. {blot_git-0.6.0 → blot_git-0.7.0}/LICENSE +0 -0
  42. {blot_git-0.6.0 → blot_git-0.7.0}/assets/demo/demo.gif +0 -0
  43. {blot_git-0.6.0 → blot_git-0.7.0}/assets/demo/make_demo.py +0 -0
  44. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/alert.svg +0 -0
  45. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/faces.svg +0 -0
  46. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/happy.svg +0 -0
  47. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/idle.svg +0 -0
  48. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/sad.svg +0 -0
  49. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/sleepy.svg +0 -0
  50. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/walk.svg +0 -0
  51. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/worried.svg +0 -0
  52. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/build_sprites.py +0 -0
  53. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/alert.svg +0 -0
  54. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/faces.svg +0 -0
  55. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/happy.svg +0 -0
  56. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/idle.svg +0 -0
  57. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/sad.svg +0 -0
  58. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/sleepy.svg +0 -0
  59. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/walk.svg +0 -0
  60. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/worried.svg +0 -0
  61. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/alert.svg +0 -0
  62. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/faces.svg +0 -0
  63. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/happy.svg +0 -0
  64. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/idle.svg +0 -0
  65. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/sad.svg +0 -0
  66. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/sleepy.svg +0 -0
  67. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/walk.svg +0 -0
  68. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/worried.svg +0 -0
  69. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/alert.svg +0 -0
  70. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/faces.svg +0 -0
  71. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/happy.svg +0 -0
  72. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/idle.svg +0 -0
  73. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/sad.svg +0 -0
  74. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/sleepy.svg +0 -0
  75. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/walk.svg +0 -0
  76. {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/worried.svg +0 -0
  77. {blot_git-0.6.0 → blot_git-0.7.0}/bench/README.md +0 -0
  78. {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/expressjs-express.json +0 -0
  79. {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/facebook-react.json +0 -0
  80. {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/psf-requests.json +0 -0
  81. {blot_git-0.6.0 → blot_git-0.7.0}/docs/EXPLAINER.md +0 -0
  82. {blot_git-0.6.0 → blot_git-0.7.0}/docs/competitors.md +0 -0
  83. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/__main__.py +0 -0
  84. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/bench.py +0 -0
  85. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/__init__.py +0 -0
  86. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/cochange.py +0 -0
  87. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/readded_bug.py +0 -0
  88. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/classify.py +0 -0
  89. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/config.py +0 -0
  90. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/git.py +0 -0
  91. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/github.py +0 -0
  92. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/guard.py +0 -0
  93. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/report.py +0 -0
  94. {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/store.py +0 -0
  95. {blot_git-0.6.0 → blot_git-0.7.0}/tests/conftest.py +0 -0
  96. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_action.py +0 -0
  97. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_bench.py +0 -0
  98. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_classify.py +0 -0
  99. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_config.py +0 -0
  100. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_git.py +0 -0
  101. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_hooks.py +0 -0
  102. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_index.py +0 -0
  103. {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_why_github.py +0 -0
@@ -1,6 +1,6 @@
1
1
  # Lets users of the pre-commit framework add blot with:
2
2
  # - repo: https://github.com/Sym-jay/Blot
3
- # rev: v0.6.0
3
+ # rev: v0.7.0
4
4
  # hooks: [{id: blot}]
5
5
  # The first commit indexes the repo's history; later commits refresh the index incrementally.
6
6
  - id: blot
@@ -7,12 +7,14 @@ always changes with another (`forgot-related-file`). Mascot: Sora the slime. Own
7
7
 
8
8
  ## Layout
9
9
  - `src/blot/`: stdlib-only on Python 3.11+ (3.10 also needs `tomli`; keep it that way)
10
- - `cli.py` commands: index, check, why, install-hook, guard, install-agent-hook, bench. `check`/`why` build/refresh the index themselves.
10
+ - `cli.py` commands: index, check, why, map, mcp, install-hook, guard, install-agent-hook, install-mcp, bench.
11
+ `check`/`why`/`map`/`mcp` build/refresh the index themselves (`index.open_index`).
11
12
  - `index.py` builds `.git/blot.db` (parallel `git log` slices for 2000+ commits); `store.py` SQLite schema v2
12
13
  - `classify.py` fix severity 0/1/2 + refactor detection: most precision lives here
13
14
  - `checks/` one file per check; `trace.py` blame that follows lines past refactor commits
15
+ - `fixmap.py` (`blot map`), `mcp.py` (stdio MCP server, both protocol eras; `install-mcp` in `hooks.py`)
14
16
  - `guard.py` (Claude Code hook; installer in `hooks.py`), `why.py`, `github.py` (`index --github` via `gh api graphql`), `bench.py` (cases + `--replay N`)
15
- - `bench/cases.json` scripted real-world cases (32/32); `bench/labels/` hand verdicts for every replay
17
+ - `bench/cases.json` scripted real-world cases (33/33); `bench/labels/` hand verdicts for every replay
16
18
  warning on the 5 reference repos; `bench/RESULTS.md` replay numbers and precision
17
19
  - `action.yml` GitHub Action; `.github/workflows/blot.yml` runs it on Blot's own PRs
18
20
  - `assets/sora/build_sprites.py` and `assets/demo/make_demo.py` regenerate the mascot and demo GIF
@@ -22,7 +24,7 @@ always changes with another (`forgot-related-file`). Mascot: Sora the slime. Own
22
24
  ```bash
23
25
  python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]"
24
26
  .venv/Scripts/python -m pytest -q
25
- .venv/Scripts/blot bench --workdir <short path> # must stay 32/32
27
+ .venv/Scripts/blot bench --workdir <short path> # must stay 33/33
26
28
  ```
27
29
  - Any change to `classify.py` or a check: on each of the 5 clones, checked out at the `head` in its
28
30
  labels file, run `blot bench --replay 500 --labels bench/labels/<repo>.json --update-labels`. Keep a
@@ -50,19 +52,24 @@ python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]"
50
52
  - Writing code through bash heredocs mangles `\b`, `\n`, `\s`: use the Edit tool for regexes/strings.
51
53
  - `gh api` paths: no leading slash (Git Bash rewrites it).
52
54
 
53
- ## State (2026-11-06)
54
- - Released 0.6.0: checks, `why`, `--github`, agent guard (`blot guard` / `install-agent-hook`, tested
55
- live in Claude Code 2026-11-06), bench/replay with labels, parallel indexing, pre-commit framework
56
- support (`rev: v0.6.0`), GitHub Action (`uses: Sym-jay/Blot@v0.6.0`).
57
- - Launch: dev.to post published 2026-10-24; the CVE sentence there should mention the withdrawn advisory.
58
- - Open issues: #2–#4, #6 (bench cases); #33 (live guard report), #34 (quiet readded-bug case), #35–#36 (noise).
59
- - Precision (0.5.0, measured 2026-10-30): 57/109 useful (52%); Django 1.19 / 10 commits, others ≤ 0.6.
55
+ ## State (2026-11-14)
56
+ - Released 0.7.0: checks, `why`, `map`, `--github`, agent guard (`blot guard` / `install-agent-hook`),
57
+ MCP server (`blot mcp` / `install-mcp`, tools `explain_lines` and `load_bearing_lines`), bench/replay
58
+ with labels, parallel indexing, pre-commit framework (`rev: v0.7.0`), GitHub Action (`@v0.7.0`).
59
+ - Live-tested in Claude Code: the guard (2026-11-06: the agent cited fix 8efea0c it was undoing) and MCP
60
+ (2026-11-14: an agent called `load_bearing_lines` on its own and answered correctly).
61
+ Throwaway test folder: `Documents lask-guard-test` (guard hook + `.mcp.json`).
62
+ - `blot map` takes ~1.5 s on Flask app.py. `trace_lines(whole_file=True)` drops `-C` from the first blame so
63
+ map and why agree; checks still use the old path.
64
+ - Precision (2026-11-11, after #40): 59/108 useful (54.6%); Django 1.17 / 10 commits, others ≤ 0.6.
60
65
  `forgot-related-file` has 0 useful warnings in 45 labelled; candidate to become opt-in.
61
- - `--github` measured (2026-11-01): precision unchanged (52.5%), +7 useful warnings, almost no demotions.
66
+ - `--github` measured (2026-11-01): precision unchanged, +7 useful warnings, almost no demotions.
62
67
  - Tried and dropped (2026-11-01): "fix word + PR number" as an offline fix rule: +85 warnings at 22%
63
68
  useful. Don't retry it without a narrower signal (see bench/RESULTS.md).
64
- - Agent guard (2026-11-04, branch `agent-guard`, unreleased): `blot guard` is a Claude Code PreToolUse
65
- hook (`src/blot/guard.py`) running removed-fix + readded-bug on a proposed Edit/Write/MultiEdit;
66
- `blot install-agent-hook` edits `.claude/settings.json`. Flask edit of `self_ref` warns in ~450 ms.
67
- Not yet tried in a live Claude Code session.
68
- - Next: remaining noise (renames, API swaps, added arguments, docstrings) in bench/RESULTS.md.
69
+ - Known recall limit: tracing only passes commits classified as refactors, so a big non-refactor rewrite
70
+ hides older fixes (e.g. Flask `c2705ffd` hides fix #2935 in `ctx.py` `copy()`).
71
+ - Labels match on commit/check/path/cited commit, not line: when a rule moves a warning to another line,
72
+ re-check its label (#40 found two that way).
73
+ - Contributors: Sandro850 (#30, #38, #40). Open issues: #2–#4, #6 (bench cases), #33 (guard reports),
74
+ #36 (keyword-argument noise).
75
+ - Launch: dev.to post published 2026-10-24; the CVE sentence there should mention the withdrawn advisory.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: blot-git
3
- Version: 0.6.0
3
+ Version: 0.7.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
@@ -43,6 +43,7 @@ cd your-repo
43
43
  blot index # optional: the first check indexes history itself
44
44
  blot install-hook # adds Blot to your pre-commit hook
45
45
  blot why src/app.py:42 # which commit explains this line, skipping refactors
46
+ blot map src/app.py # which lines of the file exist because of bug fixes
46
47
  blot index --github # optional: issue/PR titles and labels via the gh CLI
47
48
  ```
48
49
 
@@ -50,6 +51,11 @@ blot index --github # optional: issue/PR titles and labels via the gh CLI
50
51
  an edit an agent is about to make, so the agent sees "this line exists because of fix 8efea0c" before
51
52
  it "simplifies" it away. It warns by default; `--ask` makes Claude Code ask you first.
52
53
 
54
+ **Tell agents before they plan.** `blot mcp` is a stdio MCP server (standard library only) with two
55
+ read-only tools, `load_bearing_lines` (like `blot map`) and `explain_lines` (like `blot why`), so an agent
56
+ can look at a file's fixes before deciding what to change. `blot install-mcp` registers it in `.mcp.json`
57
+ for Claude Code; `--local` registers it just for you.
58
+
53
59
  > **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–9.5% of commits, and about half of its warnings (labelled by hand) are worth a reviewer's time; see the bench results on GitHub. Please report false alarms.
54
60
 
55
61
  Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
@@ -71,6 +71,61 @@ src/flask/app.py:355
71
71
 
72
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
73
 
74
+ ## Map a file's load-bearing lines
75
+
76
+ ```
77
+ $ blot map src/flask/app.py
78
+ src/flask/app.py: 3 load-bearing lines in 2 ranges, from 2 fixes
79
+
80
+ 355 | self_ref = weakref.ref(self)
81
+ 8efea0c "Break reference cycle created by default in Flask instances." (2020-10-03)
82
+ traced past refactor 0ec7f71 "Split the App and Blueprint into Sansio and IO parts"
83
+
84
+ 1061-1062 | rv = self.response_class()
85
+ | rv.allow.update(methods)
86
+ dbf55de "Fixed an issue where the default `OPTIONS` response was not exposing all valid methods in the `Allow` header." (2010-07-28, fixes #97)
87
+ traced past refactor 0ec7f71 "Split the App and Blueprint into Sansio and IO parts"
88
+ ```
89
+
90
+ `blot map FILE` runs `why` over a whole file and keeps the lines whose explaining commit is a bug fix:
91
+ the lines `blot check` would warn about if you changed them. Consecutive lines from one fix are grouped.
92
+ `--full` prints the whole file with the fix commit in the gutter, `--format json` gives the ranges and
93
+ commits as JSON, `--rev` maps an older version, and `--min-severity 1` also counts routine fixes.
94
+ On Flask's 1,625-line `app.py` it takes about 1.5 s.
95
+
96
+ ## Give agents the history before they edit (MCP)
97
+
98
+ `blot mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so a coding
99
+ agent can ask about a file *before* it plans a change, instead of hearing about it from `blot guard` at
100
+ the last moment. It has two read-only tools:
101
+
102
+ | Tool | Same as | Arguments |
103
+ |---|---|---|
104
+ | `load_bearing_lines` | `blot map FILE` | `path`, optional `rev`, `min_severity` |
105
+ | `explain_lines` | `blot why FILE:START-END` | `path`, `start`, optional `end`, `rev` |
106
+
107
+ Each returns one paragraph per commit: short sha, date, subject, linked issues, the lines it explains
108
+ and any refactor traced past. The same data comes back as JSON in `structuredContent`.
109
+
110
+ ```bash
111
+ blot install-mcp # adds `blot` to .mcp.json at the repo root and builds the index
112
+ blot install-mcp --local # or just for you, via `claude mcp add --scope local` (needs the claude CLI)
113
+ ```
114
+
115
+ Claude Code asks each person once before it starts a server from `.mcp.json`. The entry names this
116
+ machine's Python by absolute path, like `install-agent-hook` does, so prefer `--local` if your team
117
+ commits `.mcp.json`. Other servers in the file are left alone; remove Blot's entry with
118
+ `blot install-mcp --uninstall` (add `--local` if you installed it that way). To register it by hand:
119
+
120
+ ```bash
121
+ claude mcp add --scope local --transport stdio blot -- python -m blot mcp
122
+ ```
123
+
124
+ The server works on the repository it is started in (or `blot --repo PATH mcp`). It speaks MCP
125
+ 2026-07-28 and the earlier `initialize`-handshake versions (2024-11-05 to 2025-11-25), uses only the
126
+ standard library, writes nothing but protocol messages to stdout, and answers a bad path or an
127
+ untracked file with a tool error rather than exiting.
128
+
74
129
  ## Guard coding agents
75
130
 
76
131
  Coding agents are the most likely to "clean up" a line they don't understand. `blot guard` is a
@@ -127,7 +182,7 @@ jobs:
127
182
  blot:
128
183
  runs-on: ubuntu-latest
129
184
  steps:
130
- - uses: Sym-jay/Blot@v0.6.0
185
+ - uses: Sym-jay/Blot@v0.7.0
131
186
  ```
132
187
 
133
188
  The action checks out the pull request with its full history, installs Blot, runs `blot index` and
@@ -1,6 +1,6 @@
1
1
  # Run Blot on a pull request and show its findings as warning annotations.
2
2
  # Usage (in a workflow triggered by pull_request):
3
- # - uses: Sym-jay/Blot@v0.6.0
3
+ # - uses: Sym-jay/Blot@v0.7.0
4
4
  # See "Use in CI" in README.md.
5
5
  name: Blot
6
6
  description: History-aware checks on the commits of a pull request, shown as warning annotations
@@ -2,12 +2,12 @@
2
2
 
3
3
  Measured 2026-10-30 with `blot bench`, on Windows 11, Python 3.12.
4
4
 
5
- ## Cases: 32/32 correct
5
+ ## Cases: 33/33 correct
6
6
 
7
7
  | Check | Cases | Precision | Recall |
8
8
  |---|---|---|---|
9
9
  | removed-fix | 24 (16 warn, 8 quiet) | 100% | 100% |
10
- | readded-bug | 5 (4 warn, 1 quiet) | 100% | 100% |
10
+ | readded-bug | 6 (4 warn, 2 quiet) | 100% | 100% |
11
11
  | forgot-related-file | 3 (3 quiet) | n/a | n/a |
12
12
 
13
13
  A quiet case now fails if its scripted edit can't be applied (it used to pass without running).
@@ -58,7 +58,23 @@ code from earlier fixes and CVEs.
58
58
  | commits cited as fixes that weren't: `Fixed #21189: Cleaned up ...`, `Fixed #12882 - Moved ...`, `[DevTools] upgrade to Manifest V3`, `Change Flask.__init__ to accept ...`, error-message wording (6) | `Fixed #N:` / `Fixed #N -` ticket styles judged by their description; cleanups count as refactors; `[Tag]` prefixes and "change" subjects count as features; error messages are routine |
59
59
  | docstrings with `backticks` or (asides) (1) | they count as prose |
60
60
 
61
- ### Noise still there (52 warnings)
61
+ ### Since 0.6.0: real docstrings (#40, by @Sandro850)
62
+
63
+ `removed-fix` now parses the old Python file (`ast`, only when a warning is already on its way) and skips
64
+ lines inside real module, class and function docstrings. That removed one Django noise warning. It also
65
+ exposed two of **our** labels as wrong: in Flask `c2705ffd` and Django `ebcb13b3`, the warning used to point at
66
+ a docstring line, but the same change also edits the fix's code (Flask's `copy()` passing
67
+ `session=self.session`, from fix #2935; Django's `isinstance(normalized, (dict, str))` check, from the
68
+ CVE-2026-15307 fix). Now the warning points at the code, and both are relabelled useful.
69
+
70
+ | Repo | Before | After |
71
+ |---|---|---|
72
+ | pallets/flask | 3/4 (75%) | **4/4 (100%)** |
73
+ | django/django | 40/59 (68%) | **41/58 (71%)** |
74
+ | requests, Express, React | unchanged | unchanged |
75
+ | **All five** | 57/109 (52.3%) | **59/108 (54.6%)** |
76
+
77
+ ### Noise still there (49 warnings)
62
78
 
63
79
  | Pattern | Warnings | Why there's no rule yet |
64
80
  |---|---|---|
@@ -67,7 +83,6 @@ code from earlier fixes and CVEs.
67
83
  | new argument or keyword argument on a fix's call | 3 | adding an argument can change behaviour |
68
84
  | formatter reflows with f-strings or joined lines (requests' Black commit) | 4 | dropping parentheses can change precedence |
69
85
  | deleting a legacy module or feature in pieces (React) | 6 | intentional, but only the commit message says so |
70
- | docstring lines that still read as code | 3 | needs to know it's inside a docstring, not just how the line looks |
71
86
  | other: code moved to another file, fix behaviour kept while restructuring, generic re-added lines | 16 | case by case |
72
87
 
73
88
  **`forgot-related-file` has shown no useful warning yet**: 0/45 before this release, 0/9 after. It
@@ -439,5 +439,20 @@
439
439
  "warn": false
440
440
  },
441
441
  "notes": "Black's formatting change from 2a6f290b in should_strip_auth. Formatting only: must stay quiet."
442
+ },
443
+ {
444
+ "id": "requests-quiet-per-host-proxy-feature",
445
+ "repo": "https://github.com/psf/requests",
446
+ "at": "48fe34447de02bfe9827321fafe3277741d77c38",
447
+ "file": "requests/adapters.py",
448
+ "replace": [
449
+ " u = urlparse(url.lower())\n proxy = proxies.get(u.scheme+'://'+u.hostname, proxies.get(u.scheme))",
450
+ " proxy = proxies.get(urlparse(url.lower()).scheme)"
451
+ ],
452
+ "expect": {
453
+ "check": "readded-bug",
454
+ "warn": false
455
+ },
456
+ "notes": "Re-adds the exact line removed by 48fe3444. Although the commit says Fixes #2722, it implements the per-host proxy feature rather than fixing a regression. Blot classifies it with severity 1, so its removed line must not be indexed as a bug fix."
442
457
  }
443
458
  ]
@@ -579,8 +579,8 @@
579
579
  "path": "django/contrib/gis/gdal/raster/source.py",
580
580
  "line": 37,
581
581
  "cited": "f1949c1",
582
- "verdict": "noise",
583
- "reason": "docstring"
582
+ "verdict": "useful",
583
+ "reason": "extends the isinstance check from the CVE-2026-15307 fix to bytes (was mislabelled: the old warning pointed at the docstring line above)"
584
584
  },
585
585
  {
586
586
  "sha": "62ef9d40c",
@@ -49,8 +49,8 @@
49
49
  "path": "src/flask/ctx.py",
50
50
  "line": 348,
51
51
  "cited": "e08bcf9",
52
- "verdict": "noise",
53
- "reason": "docstring rewording"
52
+ "verdict": "useful",
53
+ "reason": "changes the copy() call that passes session=self.session, from fix #2935 (was mislabelled: the old warning pointed at the docstring line above)"
54
54
  }
55
55
  ]
56
56
  }
@@ -106,6 +106,20 @@ competitors tested (code-archaeologist, lore, Entire), none guards an edit befor
106
106
  - `blot install-agent-hook [--local] [--ask] [--uninstall]` edits `.claude/settings.json` (or
107
107
  `settings.local.json`) without touching other hooks.
108
108
 
109
+ ### F8. `blot map FILE` and `blot mcp`: history before the agent plans (unreleased)
110
+ - `blot map FILE [--rev R] [--format json] [--full] [--min-severity N]`: every line whose explaining commit
111
+ (traced past refactors, as `why` does) is a fix at or above `min_severity`, grouped into ranges per fix.
112
+ It considers the lines `removed-fix` guards (no blank, generic, prose or docstring lines). One blame
113
+ of the whole file plus refactor hops: ~1.5 s on Flask's 1,625-line `app.py`.
114
+ - `blot mcp`: a stdio MCP server, stdlib only. Tools `explain_lines(path, start, end?, rev?)` (= `why`) and
115
+ `load_bearing_lines(path, rev?, min_severity?)` (= `map`) return one paragraph per commit plus
116
+ `structuredContent`. It speaks MCP 2026-07-28 (`server/discover`, per-request `_meta`) and the
117
+ `initialize` handshake of 2024-11-05 to 2025-11-25. Bad paths and untracked files are tool errors
118
+ (`isError: true`); only protocol messages go to stdout.
119
+ - `blot install-mcp [--local] [--uninstall]` adds `blot` to `.mcp.json` without touching other servers,
120
+ or with `--local` runs `claude mcp add --scope local`.
121
+ - Why: `why` explains rather than warns, so unlike `guard` it isn't hurt by the checks' noise (55% precision).
122
+
109
123
  ## 6. User flows
110
124
 
111
125
  **First run (2 minutes):**
@@ -175,7 +189,7 @@ The user either reads the linked issue and adjusts the change, or commits anyway
175
189
  | GitHub only | GitLab and Bitbucket links; mailing lists (Linux, Postgres) |
176
190
  | | GitHub Action that comments on PRs |
177
191
  | | VS Code extension that shows warnings as you edit |
178
- | | MCP server, so AI agents can ask before editing (the `blot guard` hook, F7, covers Claude Code) |
192
+ | | ~~MCP server, so AI agents can ask before editing~~ done (unreleased): `blot mcp` with `explain_lines` and `load_bearing_lines`, alongside the `blot guard` hook (F7) |
179
193
 
180
194
  ## 10. Success metrics
181
195
 
@@ -204,7 +218,7 @@ The user either reads the linked issue and adjusts the change, or commits anyway
204
218
  | 1 | **M1: index + config + hook installer** | `blot index` builds the DB on Flask in under 10 s; `install-hook` is safe with existing hooks |
205
219
  | 2 | **M2: three checks** | All unit tests pass, including the "trace past refactor" test (currently skipped) |
206
220
  | 3 | **M3: `why` + `--github` + output polish** ✅ | The Flask case prints the 2020 fix, traced past the 2023 refactor; `--github` adds issue titles and labels |
207
- | 4 | **M4: bench + launch** (32 cases ✅, replay precision 52% of the 80% target, launch pending) | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
221
+ | 4 | **M4: bench + launch** (33 cases ✅, replay precision 52% of the 80% target, launch pending) | 30 bench cases, precision ≥ 80%, PyPI release, README GIF, Show HN / r/Python post |
208
222
 
209
223
  ## 13. Open questions
210
224
 
@@ -18,6 +18,7 @@ cd your-repo
18
18
  blot index # optional: the first check indexes history itself
19
19
  blot install-hook # adds Blot to your pre-commit hook
20
20
  blot why src/app.py:42 # which commit explains this line, skipping refactors
21
+ blot map src/app.py # which lines of the file exist because of bug fixes
21
22
  blot index --github # optional: issue/PR titles and labels via the gh CLI
22
23
  ```
23
24
 
@@ -25,6 +26,11 @@ blot index --github # optional: issue/PR titles and labels via the gh CLI
25
26
  an edit an agent is about to make, so the agent sees "this line exists because of fix 8efea0c" before
26
27
  it "simplifies" it away. It warns by default; `--ask` makes Claude Code ask you first.
27
28
 
29
+ **Tell agents before they plan.** `blot mcp` is a stdio MCP server (standard library only) with two
30
+ read-only tools, `load_bearing_lines` (like `blot map`) and `explain_lines` (like `blot why`), so an agent
31
+ can look at a file's fixes before deciding what to change. `blot install-mcp` registers it in `.mcp.json`
32
+ for Claude Code; `--local` registers it just for you.
33
+
28
34
  > **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–9.5% of commits, and about half of its warnings (labelled by hand) are worth a reviewer's time; see the bench results on GitHub. Please report false alarms.
29
35
 
30
36
  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.6.0"
3
+ version = "0.7.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.6.0"
3
+ __version__ = "0.7.0"
@@ -63,3 +63,13 @@ def describe_commit(row: CommitRow, store: Store | None = None) -> str:
63
63
  refs = [store.describe_issue(ref) for ref in row.issues] if store else row.issues
64
64
  details.append("fixes " + ", ".join(refs))
65
65
  return f'{row.sha[:7]} "{row.subject}" ({", ".join(details)})'
66
+
67
+
68
+ def describe_trail(row: CommitRow, via: list[str], store: Store) -> list[str]:
69
+ """describe_commit, then one 'traced past refactor 0ec7f71 "subject"' line per refactor hop."""
70
+ lines = [describe_commit(row, store)]
71
+ for sha in via:
72
+ ref = store.get_commit(sha)
73
+ if ref is not None:
74
+ lines.append(f'traced past refactor {sha[:7]} "{ref.subject}"')
75
+ return lines
@@ -13,16 +13,26 @@ normalized text, preferring the same path. That is how a file split is followed
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- from blot.checks.base import Check, Finding, describe_commit, is_skipped
17
- from blot.diff import KeptLines, is_meaningful_line, normalize_line
16
+ import subprocess
17
+
18
+ from blot.checks.base import Check, Finding, describe_trail, is_skipped
19
+ from blot.diff import (
20
+ KeptLines,
21
+ is_meaningful_line,
22
+ normalize_line,
23
+ python_docstring_lines,
24
+ )
25
+ from blot.git import run_git
18
26
  from blot.trace import RefactorOrigins, trace_lines
19
27
 
28
+
20
29
  class RemovedFix(Check):
21
30
  id = "removed-fix"
22
31
  description = "Changing code that a bug fix added"
23
32
 
24
33
  def run(self, changes, store, config) -> list[Finding]:
25
34
  origins = RefactorOrigins(store.repo)
35
+ docstrings: dict[tuple[str, str], set[int] | None] = {}
26
36
  findings: list[Finding] = []
27
37
  # a line that reappears anywhere in the change was moved, not removed
28
38
  moved = {normalize_line(text) for c in changes for _, text in c.added}
@@ -41,19 +51,33 @@ class RemovedFix(Check):
41
51
  continue
42
52
 
43
53
  trail = trace_lines(store, store.rev, change.old_path, texts, origins)
54
+ candidates = []
55
+ for n in sorted(trail):
56
+ row = store.get_commit(trail[n].sha)
57
+ if row is not None and not row.is_refactor and row.severity >= config.min_severity:
58
+ candidates.append((n, trail[n], row))
59
+ if not candidates:
60
+ continue
61
+
62
+ docstring_lines: set[int] | None = None
63
+ if change.old_path.endswith(".py"):
64
+ key = (store.rev, change.old_path)
65
+ if key not in docstrings:
66
+ try:
67
+ source = run_git(store.repo, "show", f"{store.rev}:{change.old_path}")
68
+ except subprocess.CalledProcessError:
69
+ docstrings[key] = None
70
+ else:
71
+ docstrings[key] = python_docstring_lines(source)
72
+ docstring_lines = docstrings[key]
44
73
 
45
74
  reported: set[str] = set()
46
- for n in sorted(trail):
47
- sha, via = trail[n].sha, trail[n].via
48
- row = store.get_commit(sha)
49
- if row is None or row.is_refactor or row.severity < config.min_severity or sha in reported:
75
+ for n, traced, row in candidates:
76
+ sha, via = traced.sha, traced.via
77
+ if (docstring_lines is not None and n in docstring_lines) or sha in reported:
50
78
  continue
51
79
  reported.add(sha) # one finding per fix per file, at its first line
52
- evidence = [describe_commit(row, store)]
53
- for ref_sha in via:
54
- ref = store.get_commit(ref_sha)
55
- if ref is not None:
56
- evidence.append(f'traced past refactor {ref_sha[:7]} "{ref.subject}"')
80
+ evidence = describe_trail(row, via, store)
57
81
  findings.append(Finding(
58
82
  self.id, change.old_path, n,
59
83
  "You're removing or changing a line that a bug fix added.\n"
@@ -4,9 +4,12 @@ Commands:
4
4
  index build or refresh the local history index (.git/blot.db)
5
5
  check run all checks against staged changes (pre-commit) or a commit range
6
6
  why show the history behind a line: fix commits, PR, linked issues
7
+ map show which lines of a file exist because of bug fixes
8
+ mcp MCP server on stdio for coding agents (explain_lines, load_bearing_lines)
7
9
  install-hook add blot to .git/hooks/pre-commit
8
10
  guard Claude Code hook: warn a coding agent before it edits a line a fix added
9
11
  install-agent-hook add `blot guard` to .claude/settings.json
12
+ install-mcp add `blot mcp` to .mcp.json (or `claude mcp add --scope local`)
10
13
  bench measure accuracy: bench cases, or replay recent commits (--replay N)
11
14
  """
12
15
 
@@ -46,19 +49,9 @@ def _cmd_index(args: argparse.Namespace) -> int:
46
49
 
47
50
 
48
51
  def _open_index(repo: str):
49
- """Open the index, building or refreshing it first.
52
+ from blot.index import open_index
50
53
 
51
- The pre-commit framework installs Blot where users can't easily run `blot index`, so the hook
52
- must look after the index itself: one full build on first use, then cheap incremental refreshes.
53
- """
54
- from blot.index import build_index
55
- from blot.store import Store
56
-
57
- if not os.path.exists(Store.db_path(repo)):
58
- print("blot: first run, indexing this repo's history once (big repos can take a minute)...",
59
- file=sys.stderr)
60
- build_index(repo)
61
- return Store.open(repo)
54
+ return open_index(repo)
62
55
 
63
56
 
64
57
  def _cmd_check(args: argparse.Namespace) -> int:
@@ -93,6 +86,48 @@ def _cmd_why(args: argparse.Namespace) -> int:
93
86
  return 0
94
87
 
95
88
 
89
+ def _cmd_map(args: argparse.Namespace) -> int:
90
+ import json
91
+
92
+ from blot.fixmap import build_map, render, to_json
93
+ from blot.why import WhyError
94
+
95
+ min_severity = args.min_severity if args.min_severity is not None else load_config(args.repo).min_severity
96
+ store = _open_index(args.repo)
97
+ try:
98
+ fmap = build_map(store, args.file, args.rev, min_severity)
99
+ text = json.dumps(to_json(store, fmap), indent=2) + "\n" if args.format == "json" else render(store, fmap, args.full)
100
+ except WhyError as exc:
101
+ print(f"blot: {exc}", file=sys.stderr)
102
+ return 1
103
+ finally:
104
+ store.close()
105
+ print(text, end="")
106
+ return 0
107
+
108
+
109
+ def _cmd_mcp(args: argparse.Namespace) -> int:
110
+ from blot.mcp import serve_stdio
111
+
112
+ return serve_stdio(args.repo)
113
+
114
+
115
+ def _cmd_install_mcp(args: argparse.Namespace) -> int:
116
+ from blot import hooks
117
+
118
+ if args.uninstall:
119
+ print(f"blot: MCP server {hooks.uninstall_mcp(args.repo, local=args.local)}")
120
+ return 0
121
+ try:
122
+ status, where = hooks.install_mcp(args.repo, local=args.local)
123
+ except (ValueError, RuntimeError) as exc:
124
+ print(f"blot: not installed: {exc}", file=sys.stderr)
125
+ return 1
126
+ print(f"blot: MCP server {status} ({where})")
127
+ _open_index(args.repo).close() # a first index can take a minute: build it now, not in a tool call
128
+ return 0
129
+
130
+
96
131
  def _cmd_install_hook(args: argparse.Namespace) -> int:
97
132
  from blot import hooks
98
133
 
@@ -192,6 +227,24 @@ def build_parser() -> argparse.ArgumentParser:
192
227
  p.add_argument("--rev", default="HEAD", help="explain the file as of this commit (default: HEAD)")
193
228
  p.set_defaults(func=_cmd_why)
194
229
 
230
+ p = sub.add_parser("map", help="show which lines of a file exist because of bug fixes")
231
+ p.add_argument("file", metavar="FILE", help="e.g. src/app.py")
232
+ p.add_argument("--rev", default="HEAD", help="map the file as of this commit (default: HEAD)")
233
+ p.add_argument("--format", choices=["text", "json"], default="text")
234
+ p.add_argument("--full", action="store_true", help="print the whole file with fix commits in the gutter")
235
+ p.add_argument("--min-severity", type=int, choices=[1, 2], default=None,
236
+ help="1 also counts routine fixes (default: min-severity from config, normally 2)")
237
+ p.set_defaults(func=_cmd_map)
238
+
239
+ p = sub.add_parser("mcp", help="run an MCP server on stdio (explain_lines, load_bearing_lines) for coding agents")
240
+ p.set_defaults(func=_cmd_mcp)
241
+
242
+ p = sub.add_parser("install-mcp", help="register `blot mcp` with Claude Code (.mcp.json)")
243
+ p.add_argument("--uninstall", action="store_true", help="remove blot's entry")
244
+ p.add_argument("--local", action="store_true",
245
+ help="just for you, in this project (runs `claude mcp add --scope local`) instead of .mcp.json")
246
+ p.set_defaults(func=_cmd_install_mcp)
247
+
195
248
  p = sub.add_parser("install-hook", help="add blot to .git/hooks/pre-commit")
196
249
  p.add_argument("--uninstall", action="store_true", help="remove blot's lines from the hook")
197
250
  p.set_defaults(func=_cmd_install_hook)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
5
6
  import hashlib
6
7
  import re
7
8
  from dataclasses import dataclass, field
@@ -105,6 +106,30 @@ def is_meaningful_line(normalized: str) -> bool:
105
106
  return bool(normalized) and not is_trivial_line(normalized) and not is_prose_line(normalized)
106
107
 
107
108
 
109
+ def python_docstring_lines(source: str) -> set[int] | None:
110
+ """Line numbers occupied by real Python docstrings, or None if parsing fails."""
111
+ try:
112
+ tree = ast.parse(source)
113
+ except (SyntaxError, ValueError, RecursionError):
114
+ return None
115
+
116
+ lines: set[int] = set()
117
+ scopes = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
118
+ for node in ast.walk(tree):
119
+ if not isinstance(node, scopes) or not node.body:
120
+ continue
121
+ first = node.body[0]
122
+ if not (
123
+ isinstance(first, ast.Expr)
124
+ and isinstance(first.value, ast.Constant)
125
+ and isinstance(first.value.value, str)
126
+ ):
127
+ continue
128
+ end = first.value.end_lineno or first.value.lineno
129
+ lines.update(range(first.value.lineno, end + 1))
130
+ return lines
131
+
132
+
108
133
  def line_hash(normalized: str) -> str:
109
134
  return hashlib.blake2b(normalized.encode("utf-8"), digest_size=8).hexdigest()
110
135