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.
- {blot_git-0.6.0 → blot_git-0.7.0}/.pre-commit-hooks.yaml +1 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/CLAUDE.md +23 -16
- {blot_git-0.6.0 → blot_git-0.7.0}/PKG-INFO +7 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/README.md +56 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/action.yml +1 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/RESULTS.md +19 -4
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/cases.json +15 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/django-django.json +2 -2
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/pallets-flask.json +2 -2
- {blot_git-0.6.0 → blot_git-0.7.0}/docs/PRD.md +16 -2
- {blot_git-0.6.0 → blot_git-0.7.0}/docs/PYPI.md +6 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/pyproject.toml +1 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/__init__.py +1 -1
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/base.py +10 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/removed_fix.py +35 -11
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/cli.py +65 -12
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/diff.py +25 -0
- blot_git-0.7.0/src/blot/fixmap.py +135 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/hooks.py +101 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/index.py +16 -0
- blot_git-0.7.0/src/blot/mcp.py +383 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/trace.py +60 -23
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/why.py +28 -4
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_checks.py +143 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_cli.py +28 -0
- blot_git-0.7.0/tests/test_diff.py +80 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_guard.py +59 -0
- blot_git-0.7.0/tests/test_map.py +101 -0
- blot_git-0.7.0/tests/test_mcp.py +199 -0
- blot_git-0.6.0/tests/test_diff.py +0 -39
- {blot_git-0.6.0 → blot_git-0.7.0}/.gitattributes +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/pull_request_template.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/blot.yml +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/ci.yml +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.github/workflows/release.yml +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/.gitignore +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/CONTRIBUTING.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/LICENSE +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/demo/demo.gif +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/demo/make_demo.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/alert.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/faces.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/happy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/idle.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/sad.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/sleepy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/walk.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/blue/worried.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/build_sprites.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/alert.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/faces.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/happy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/idle.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/sad.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/sleepy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/walk.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/mustard/worried.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/alert.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/faces.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/happy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/idle.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/sad.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/sleepy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/walk.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/orange/worried.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/alert.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/faces.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/happy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/idle.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/sad.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/sleepy.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/walk.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/assets/sora/sage/worried.svg +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/README.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/expressjs-express.json +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/facebook-react.json +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/bench/labels/psf-requests.json +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/docs/EXPLAINER.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/docs/competitors.md +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/__main__.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/bench.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/__init__.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/cochange.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/checks/readded_bug.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/classify.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/config.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/git.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/github.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/guard.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/report.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/src/blot/store.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/conftest.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_action.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_bench.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_classify.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_config.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_git.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_hooks.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_index.py +0 -0
- {blot_git-0.6.0 → blot_git-0.7.0}/tests/test_why_github.py +0 -0
|
@@ -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.
|
|
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 (
|
|
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
|
|
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-
|
|
54
|
-
- Released 0.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
|
|
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: `Documentslask-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
|
|
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
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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 |
|
|
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
|
-
###
|
|
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": "
|
|
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": "
|
|
53
|
-
"reason": "docstring
|
|
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
|
|
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** (
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
|
47
|
-
sha, via =
|
|
48
|
-
|
|
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 =
|
|
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
|
-
|
|
52
|
+
from blot.index import open_index
|
|
50
53
|
|
|
51
|
-
|
|
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
|
|