blot-git 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {blot_git-0.1.0 → blot_git-0.2.0}/PKG-INFO +2 -2
- {blot_git-0.1.0 → blot_git-0.2.0}/README.md +1 -1
- blot_git-0.2.0/bench/README.md +42 -0
- blot_git-0.2.0/bench/RESULTS.md +73 -0
- blot_git-0.2.0/bench/cases.json +83 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/docs/PYPI.md +1 -1
- {blot_git-0.1.0 → blot_git-0.2.0}/pyproject.toml +1 -1
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/__init__.py +1 -1
- blot_git-0.2.0/src/blot/bench.py +250 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/checks/base.py +22 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/checks/cochange.py +3 -5
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/checks/readded_bug.py +13 -15
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/checks/removed_fix.py +13 -7
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/classify.py +47 -5
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/cli.py +31 -3
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/config.py +7 -1
- blot_git-0.2.0/src/blot/diff.py +147 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/index.py +14 -6
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/store.py +20 -9
- blot_git-0.2.0/tests/test_bench.py +111 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_classify.py +11 -0
- blot_git-0.1.0/bench/README.md +0 -19
- blot_git-0.1.0/bench/cases.json +0 -17
- blot_git-0.1.0/src/blot/diff.py +0 -68
- {blot_git-0.1.0 → blot_git-0.2.0}/.gitattributes +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bench_case.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/ISSUE_TEMPLATE/check_idea.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/pull_request_template.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/workflows/ci.yml +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.github/workflows/release.yml +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.gitignore +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/.pre-commit-hooks.yaml +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/CONTRIBUTING.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/LICENSE +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/demo/demo.gif +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/demo/make_demo.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/alert.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/faces.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/happy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/idle.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/sad.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/sleepy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/walk.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/blue/worried.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/build_sprites.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/alert.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/faces.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/happy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/idle.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/sad.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/sleepy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/walk.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/mustard/worried.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/alert.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/faces.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/happy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/idle.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/sad.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/sleepy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/walk.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/orange/worried.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/alert.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/faces.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/happy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/idle.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/sad.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/sleepy.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/walk.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/assets/sora/sage/worried.svg +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/docs/EXPLAINER.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/docs/PRD.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/docs/competitors.md +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/__main__.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/checks/__init__.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/git.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/hooks.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/src/blot/report.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/conftest.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_checks.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_cli.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_config.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_diff.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_git.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.0}/tests/test_hooks.py +0 -0
- {blot_git-0.1.0 → blot_git-0.2.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.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Sym-jay/Blot
|
|
6
6
|
Project-URL: Issues, https://github.com/Sym-jay/Blot/issues
|
|
@@ -44,6 +44,6 @@ blot index # one-time, builds .git/blot.db
|
|
|
44
44
|
blot install-hook # adds Blot to your pre-commit hook
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
> **Status:** pre-alpha. All three checks work
|
|
47
|
+
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
48
48
|
|
|
49
49
|
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.
|
|
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
|
|
|
@@ -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
|
+
]
|
|
@@ -19,6 +19,6 @@ blot index # one-time, builds .git/blot.db
|
|
|
19
19
|
blot install-hook # adds Blot to your pre-commit hook
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
> **Status:** pre-alpha. All three checks work
|
|
22
|
+
> **Status:** pre-alpha. All three checks work. Replayed on 500 recent commits each of Flask, requests, Express, Django and React, Blot warns on 0.6–10% of commits; see the bench results on GitHub. Please report false alarms.
|
|
23
23
|
|
|
24
24
|
Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "blot-git"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A pre-commit check that has read your project's history: warns when a change removes a fix, re-adds a fixed bug, or forgets a file that always changes too."
|
|
5
5
|
readme = "docs/PYPI.md" # text-only; the GitHub README's relative Sora images break on PyPI
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,250 @@
|
|
|
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 fixes_that_removed(self, line_hash: str) -> list[tuple[str, str]]:
|
|
54
|
+
return [(p, s) for p, s in self._full.fixes_that_removed(line_hash) if self._is_ancestor(s)]
|
|
55
|
+
|
|
56
|
+
def partners(self, path: str, min_support: int, min_confidence: float) -> list[tuple[str, int, float]]:
|
|
57
|
+
return self._early.partners(path, min_support, min_confidence)
|
|
58
|
+
|
|
59
|
+
def _is_ancestor(self, sha: str) -> bool:
|
|
60
|
+
key = (sha, self.rev)
|
|
61
|
+
if key not in self._ancestor:
|
|
62
|
+
done = subprocess.run(["git", "-C", self.repo, "merge-base", "--is-ancestor", sha, self.rev],
|
|
63
|
+
capture_output=True)
|
|
64
|
+
self._ancestor[key] = done.returncode == 0
|
|
65
|
+
return self._ancestor[key]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ReplayCommit:
|
|
70
|
+
sha: str
|
|
71
|
+
subject: str
|
|
72
|
+
findings: list[dict]
|
|
73
|
+
ms: int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ReplayResult:
|
|
78
|
+
repo: str
|
|
79
|
+
head: str
|
|
80
|
+
checked: int
|
|
81
|
+
skipped_large: int
|
|
82
|
+
seconds: float
|
|
83
|
+
commits: list[ReplayCommit] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def warned(self) -> list[ReplayCommit]:
|
|
87
|
+
return [c for c in self.commits if c.findings]
|
|
88
|
+
|
|
89
|
+
def by_check(self) -> dict[str, int]:
|
|
90
|
+
counts = {cls.id: 0 for cls in REGISTRY}
|
|
91
|
+
for c in self.commits:
|
|
92
|
+
for f in c.findings:
|
|
93
|
+
counts[f["check"]] = counts.get(f["check"], 0) + 1
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def replay(repo: str, count: int, config: Config, max_files: int = 50, progress=None) -> ReplayResult:
|
|
98
|
+
start = time.perf_counter()
|
|
99
|
+
head = run_git(repo, "rev-parse", "HEAD").strip()
|
|
100
|
+
commits = run_git(repo, "rev-list", "--no-merges", f"--max-count={count}", "HEAD").split()[::-1]
|
|
101
|
+
tmp = tempfile.mkdtemp(prefix="blot-replay-")
|
|
102
|
+
try:
|
|
103
|
+
build_index(repo, full=True, config=config, db_path=os.path.join(tmp, "full.db"))
|
|
104
|
+
oldest_parent = _parent(repo, commits[0]) if commits else None
|
|
105
|
+
early_db = os.path.join(tmp, "early.db")
|
|
106
|
+
build_index(repo, full=True, config=config, upto=oldest_parent or commits[0], db_path=early_db)
|
|
107
|
+
full, early = Store.open(repo, os.path.join(tmp, "full.db")), Store.open(repo, early_db)
|
|
108
|
+
store = _ReplayStore(repo, full, early)
|
|
109
|
+
checks = all_checks(config)
|
|
110
|
+
result = ReplayResult(repo, head, 0, 0, 0.0)
|
|
111
|
+
for i, sha in enumerate(commits, 1):
|
|
112
|
+
parent = _parent(repo, sha)
|
|
113
|
+
if parent is None:
|
|
114
|
+
continue
|
|
115
|
+
diff = run_git(repo, "diff", "-U0", "--no-color", "--no-ext-diff", "-M", parent, sha)
|
|
116
|
+
changes = parse_unified_diff(diff)
|
|
117
|
+
if len(changes) > max_files:
|
|
118
|
+
result.skipped_large += 1
|
|
119
|
+
continue
|
|
120
|
+
store.rev = parent
|
|
121
|
+
t = time.perf_counter()
|
|
122
|
+
findings: list[Finding] = [f for check in checks for f in check.run(changes, store, config)]
|
|
123
|
+
ms = int((time.perf_counter() - t) * 1000)
|
|
124
|
+
subject = run_git(repo, "log", "-1", "--format=%s", sha).strip()
|
|
125
|
+
result.commits.append(ReplayCommit(sha, subject, [asdict(f) for f in findings], ms))
|
|
126
|
+
result.checked += 1
|
|
127
|
+
if progress:
|
|
128
|
+
progress(i, len(commits))
|
|
129
|
+
full.close()
|
|
130
|
+
early.close()
|
|
131
|
+
result.seconds = time.perf_counter() - start
|
|
132
|
+
return result
|
|
133
|
+
finally:
|
|
134
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parent(repo: str, sha: str) -> str | None:
|
|
138
|
+
parts = run_git(repo, "rev-list", "--parents", "-n", "1", sha).split()
|
|
139
|
+
return parts[1] if len(parts) > 1 else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_replay(result: ReplayResult) -> str:
|
|
143
|
+
per10 = 10 * sum(len(c.findings) for c in result.commits) / max(result.checked, 1)
|
|
144
|
+
lines = [
|
|
145
|
+
f"blot bench --replay: {result.checked} commits checked in {result.seconds:.0f}s "
|
|
146
|
+
f"({result.skipped_large} skipped as too large)",
|
|
147
|
+
f" commits with a warning: {len(result.warned)} ({100 * len(result.warned) / max(result.checked, 1):.1f}%)",
|
|
148
|
+
f" warnings per 10 commits: {per10:.2f}",
|
|
149
|
+
" by check: " + ", ".join(f"{k} {v}" for k, v in result.by_check().items()),
|
|
150
|
+
]
|
|
151
|
+
for c in result.warned:
|
|
152
|
+
lines.append(f"\n {c.sha[:9]} {c.subject}")
|
|
153
|
+
for f in c.findings:
|
|
154
|
+
where = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
|
|
155
|
+
lines.append(f" ! {f['check']} {where}")
|
|
156
|
+
lines.extend(f" {e}" for e in f["evidence"])
|
|
157
|
+
return "\n".join(lines) + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- cases ------------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class CaseResult:
|
|
165
|
+
id: str
|
|
166
|
+
check: str
|
|
167
|
+
expected_warn: bool
|
|
168
|
+
warned: bool
|
|
169
|
+
cited_right_commit: bool
|
|
170
|
+
detail: str = ""
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def correct(self) -> bool:
|
|
174
|
+
return self.cited_right_commit if self.expected_warn else not self.warned
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _repo_dir(url: str, workdir: str) -> str:
|
|
178
|
+
# always a fresh clone under workdir: the bench checks out old commits and edits files,
|
|
179
|
+
# so it must never run inside a repo the user is working in (even if `repo` is a local path)
|
|
180
|
+
name = re.split(r"[\\/]", url.rstrip("/\\"))[-1].removesuffix(".git") or "repo"
|
|
181
|
+
path = os.path.join(workdir, name)
|
|
182
|
+
os.makedirs(workdir, exist_ok=True)
|
|
183
|
+
if not os.path.isdir(os.path.join(path, ".git")):
|
|
184
|
+
subprocess.run(["git", "clone", "-q", url, path], check=True)
|
|
185
|
+
return path
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_case(case: dict, workdir: str, config: Config) -> CaseResult:
|
|
189
|
+
expect = case["expect"]
|
|
190
|
+
repo = _repo_dir(case["repo"], workdir)
|
|
191
|
+
at = case["at"]
|
|
192
|
+
if subprocess.run(["git", "-C", repo, "cat-file", "-e", f"{at}^{{commit}}"], capture_output=True).returncode:
|
|
193
|
+
subprocess.run(["git", "-C", repo, "fetch", "-q", "origin"], check=True)
|
|
194
|
+
run_git(repo, "checkout", "-q", "--detach", "--force", at)
|
|
195
|
+
db = os.path.join(workdir, f"{os.path.basename(repo)}-{at[:12]}.db")
|
|
196
|
+
build_index(repo, config=config, upto=at, db_path=db)
|
|
197
|
+
|
|
198
|
+
path, (old, new) = case["file"], case["replace"]
|
|
199
|
+
file = os.path.join(repo, path)
|
|
200
|
+
with open(file, encoding="utf-8", newline="") as f:
|
|
201
|
+
text = f.read()
|
|
202
|
+
if text.count(old) != 1:
|
|
203
|
+
return CaseResult(case["id"], expect["check"], expect["warn"], False, False,
|
|
204
|
+
f"edit text found {text.count(old)} times in {path} (needs exactly 1)")
|
|
205
|
+
try:
|
|
206
|
+
with open(file, "w", encoding="utf-8", newline="") as f:
|
|
207
|
+
f.write(text.replace(old, new))
|
|
208
|
+
changes = parse_unified_diff(run_git(repo, "diff", "-U0", "--no-color", "--no-ext-diff", "--", path))
|
|
209
|
+
store = Store.open(repo, db)
|
|
210
|
+
store.rev = at
|
|
211
|
+
check = next(cls() for cls in REGISTRY if cls.id == expect["check"])
|
|
212
|
+
findings = check.run(changes, store, config)
|
|
213
|
+
store.close()
|
|
214
|
+
finally:
|
|
215
|
+
run_git(repo, "checkout", "-q", "--", path)
|
|
216
|
+
|
|
217
|
+
want = expect.get("commit", "")
|
|
218
|
+
cited = bool(findings) and any(e.startswith(want[:7]) for f in findings for e in f.evidence) if want else bool(findings)
|
|
219
|
+
detail = "; ".join(e for f in findings for e in f.evidence[:1]) or "no warning"
|
|
220
|
+
return CaseResult(case["id"], expect["check"], expect["warn"], bool(findings), cited, detail)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run_cases(cases_path: str, workdir: str, config: Config) -> list[CaseResult]:
|
|
224
|
+
with open(cases_path, encoding="utf-8") as f:
|
|
225
|
+
cases = json.load(f)
|
|
226
|
+
return [run_case(case, workdir, config) for case in cases]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def format_cases(results: list[CaseResult]) -> str:
|
|
230
|
+
lines = []
|
|
231
|
+
for r in results:
|
|
232
|
+
mark = "ok " if r.correct else "FAIL"
|
|
233
|
+
want = "warn" if r.expected_warn else "quiet"
|
|
234
|
+
lines.append(f" {mark} {r.id} [{r.check}, expect {want}] {r.detail}")
|
|
235
|
+
lines.append("")
|
|
236
|
+
for cls in REGISTRY:
|
|
237
|
+
rs = [r for r in results if r.check == cls.id]
|
|
238
|
+
if not rs:
|
|
239
|
+
continue
|
|
240
|
+
tp = sum(r.expected_warn and r.cited_right_commit for r in rs)
|
|
241
|
+
fn = sum(r.expected_warn and not r.cited_right_commit for r in rs)
|
|
242
|
+
fp = sum(not r.expected_warn and r.warned for r in rs)
|
|
243
|
+
precision = tp / (tp + fp) if tp + fp else None
|
|
244
|
+
recall = tp / (tp + fn) if tp + fn else None
|
|
245
|
+
fmt = lambda v: "n/a" if v is None else f"{v:.0%}" # noqa: E731
|
|
246
|
+
lines.append(f" {cls.id}: {len(rs)} cases, precision {fmt(precision)}, recall {fmt(recall)} "
|
|
247
|
+
f"(tp {tp}, fp {fp}, fn {fn})")
|
|
248
|
+
correct = sum(r.correct for r in results)
|
|
249
|
+
lines.append(f"blot bench: {correct}/{len(results)} cases correct")
|
|
250
|
+
return "\n".join(lines) + "\n"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from fnmatch import fnmatch
|
|
7
8
|
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
@@ -31,6 +32,27 @@ class Check(ABC):
|
|
|
31
32
|
"""Return findings for the staged changes. Must not call the network; target < 100 ms."""
|
|
32
33
|
|
|
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
|
+
|
|
34
56
|
def describe_commit(row: CommitRow) -> str:
|
|
35
57
|
"""Evidence line: 'abc1234 "subject" (2020-10-03, fixes #3761)'."""
|
|
36
58
|
details = [row.date[:10]]
|
|
@@ -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
|
|
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:
|
|
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
|
|
@@ -13,14 +13,14 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import subprocess
|
|
15
15
|
|
|
16
|
-
from blot.checks.base import Check, Finding, describe_commit
|
|
17
|
-
from blot.diff import
|
|
18
|
-
from blot.git import run_git
|
|
16
|
+
from blot.checks.base import Check, Finding, describe_commit, is_skipped
|
|
17
|
+
from blot.diff import is_meaningful_line, line_hash, normalize_line
|
|
18
|
+
from blot.git import run_git
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _head_lines(repo: str, path: str) -> set[str]:
|
|
21
|
+
def _head_lines(repo: str, path: str, rev: str = "HEAD") -> set[str]:
|
|
22
22
|
try:
|
|
23
|
-
text = run_git(repo, "show", f"
|
|
23
|
+
text = run_git(repo, "show", f"{rev}:{path}")
|
|
24
24
|
except subprocess.CalledProcessError:
|
|
25
25
|
return set()
|
|
26
26
|
return {normalize_line(line) for line in text.splitlines()}
|
|
@@ -32,27 +32,25 @@ class ReaddedBug(Check):
|
|
|
32
32
|
|
|
33
33
|
def run(self, changes, store, config) -> list[Finding]:
|
|
34
34
|
findings: list[Finding] = []
|
|
35
|
-
|
|
35
|
+
# a line removed elsewhere in the same change was moved, not re-added
|
|
36
|
+
moved = {normalize_line(text) for c in changes for _, text in c.removed}
|
|
36
37
|
for change in changes:
|
|
38
|
+
if is_skipped(change.path, config):
|
|
39
|
+
continue
|
|
40
|
+
# only fixes to this same file count: matches in other files were mostly coincidence
|
|
37
41
|
paths = {change.path, change.old_path} - {None}
|
|
38
42
|
head_lines: set[str] | None = None
|
|
39
43
|
reported: set[str] = set()
|
|
40
44
|
for number, text in change.added:
|
|
41
45
|
norm = normalize_line(text)
|
|
42
|
-
if
|
|
46
|
+
if not is_meaningful_line(norm) or norm in moved:
|
|
43
47
|
continue
|
|
44
|
-
|
|
45
|
-
if not hits:
|
|
46
|
-
continue
|
|
47
|
-
if any(p not in paths for p, _ in hits) and head_files is None:
|
|
48
|
-
head_files = tracked_files(store.repo)
|
|
49
|
-
# a fix on another path only counts if that path is gone (renamed or moved since)
|
|
50
|
-
shas = [sha for p, sha in hits if p in paths or p not in head_files]
|
|
48
|
+
shas = [sha for p, sha in store.fixes_that_removed(line_hash(norm)) if p in paths]
|
|
51
49
|
rows = [r for r in map(store.get_commit, shas) if r and r.severity >= config.min_severity]
|
|
52
50
|
if not rows:
|
|
53
51
|
continue
|
|
54
52
|
if head_lines is None:
|
|
55
|
-
head_lines = _head_lines(store.repo, change.old_path) if change.old_path else set()
|
|
53
|
+
head_lines = _head_lines(store.repo, change.old_path, store.rev) if change.old_path else set()
|
|
56
54
|
if norm in head_lines:
|
|
57
55
|
continue
|
|
58
56
|
row = max(rows, key=lambda r: r.date) # the most recent fix that removed it
|