blot-git 0.0.1__py3-none-any.whl
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/__init__.py +3 -0
- blot/__main__.py +3 -0
- blot/checks/__init__.py +18 -0
- blot/checks/base.py +31 -0
- blot/checks/cochange.py +24 -0
- blot/checks/readded_bug.py +23 -0
- blot/checks/removed_fix.py +22 -0
- blot/classify.py +88 -0
- blot/cli.py +108 -0
- blot/config.py +71 -0
- blot/diff.py +68 -0
- blot/git.py +17 -0
- blot/hooks.py +77 -0
- blot/index.py +144 -0
- blot/report.py +24 -0
- blot/store.py +163 -0
- blot_git-0.0.1.dist-info/METADATA +46 -0
- blot_git-0.0.1.dist-info/RECORD +21 -0
- blot_git-0.0.1.dist-info/WHEEL +4 -0
- blot_git-0.0.1.dist-info/entry_points.txt +2 -0
- blot_git-0.0.1.dist-info/licenses/LICENSE +21 -0
blot/__init__.py
ADDED
blot/__main__.py
ADDED
blot/checks/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Check registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from blot.checks.base import Check, Finding
|
|
6
|
+
from blot.checks.cochange import ForgotRelatedFile
|
|
7
|
+
from blot.checks.readded_bug import ReaddedBug
|
|
8
|
+
from blot.checks.removed_fix import RemovedFix
|
|
9
|
+
from blot.config import Config
|
|
10
|
+
|
|
11
|
+
REGISTRY: list[type[Check]] = [RemovedFix, ReaddedBug, ForgotRelatedFile]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def all_checks(config: Config) -> list[Check]:
|
|
15
|
+
return [cls() for cls in REGISTRY if cls.id not in config.disabled_checks]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["REGISTRY", "Check", "Finding", "all_checks"]
|
blot/checks/base.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The interface every check implements. Adding a check = one new file + one registry line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from blot.config import Config
|
|
11
|
+
from blot.diff import FileChange
|
|
12
|
+
from blot.store import Store
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Finding:
|
|
17
|
+
check: str # check id, e.g. "removed-fix"
|
|
18
|
+
path: str
|
|
19
|
+
line: int | None # None for file-level findings (forgot-related-file)
|
|
20
|
+
message: str # one or two plain-English sentences
|
|
21
|
+
evidence: list[str] = field(default_factory=list) # 'abc1234 "subject" (date, fixes #12)'
|
|
22
|
+
severity: int = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Check(ABC):
|
|
26
|
+
id: str = ""
|
|
27
|
+
description: str = ""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def run(self, changes: list[FileChange], store: Store, config: Config) -> list[Finding]:
|
|
31
|
+
"""Return findings for the staged changes. Must not call the network; target < 100 ms."""
|
blot/checks/cochange.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Check 3: you changed A, but B almost always changes with A, and B isn't staged.
|
|
2
|
+
|
|
3
|
+
Algorithm (association rules over commit history):
|
|
4
|
+
support(A, B) = number of commits touching both A and B
|
|
5
|
+
confidence(A→B) = support(A, B) / commits touching A
|
|
6
|
+
warn when confidence >= config.cochange_min_confidence
|
|
7
|
+
and support >= config.cochange_min_support
|
|
8
|
+
and B exists at HEAD and is not part of the change
|
|
9
|
+
Commits touching more than config.cochange_max_files_per_commit files are ignored
|
|
10
|
+
(mass reformatting and renames would otherwise link everything to everything).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from blot.checks.base import Check, Finding
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForgotRelatedFile(Check):
|
|
19
|
+
id = "forgot-related-file"
|
|
20
|
+
description = "Changed a file without the file that usually changes with it"
|
|
21
|
+
|
|
22
|
+
def run(self, changes, store, config) -> list[Finding]:
|
|
23
|
+
# TODO(M2): implement the algorithm above; at most 1 finding per staged file
|
|
24
|
+
return []
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Check 2: you're adding back a line that a load-bearing bug fix deliberately removed.
|
|
2
|
+
|
|
3
|
+
Algorithm:
|
|
4
|
+
1. normalize each added line (strip whitespace and trailing comments)
|
|
5
|
+
2. skip trivial lines: shorter than 12 chars, only brackets/keywords, imports
|
|
6
|
+
3. look up its hash in fix_removed_lines (same path, or any path if it was renamed)
|
|
7
|
+
4. skip if an identical line still exists elsewhere in the file at HEAD
|
|
8
|
+
(then the fix did not remove the idea, only one copy)
|
|
9
|
+
5. warn with the fix commit that removed it
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from blot.checks.base import Check, Finding
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReaddedBug(Check):
|
|
18
|
+
id = "readded-bug"
|
|
19
|
+
description = "Re-adding a line that a bug fix removed"
|
|
20
|
+
|
|
21
|
+
def run(self, changes, store, config) -> list[Finding]:
|
|
22
|
+
# TODO(M2): implement the algorithm above
|
|
23
|
+
return []
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Check 1: you're deleting or editing lines that a load-bearing bug fix added.
|
|
2
|
+
|
|
3
|
+
Algorithm:
|
|
4
|
+
1. for each file, take the removed lines (old line numbers)
|
|
5
|
+
2. `git blame -w -M -C --porcelain -L a,b HEAD -- <old_path>` for those ranges
|
|
6
|
+
3. if the blamed commit is a refactor (classify.is_refactor), re-blame at its parent
|
|
7
|
+
on the original path, up to 3 hops
|
|
8
|
+
4. warn if the final commit's severity >= config.min_severity
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from blot.checks.base import Check, Finding
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RemovedFix(Check):
|
|
17
|
+
id = "removed-fix"
|
|
18
|
+
description = "Changing code that a bug fix added"
|
|
19
|
+
|
|
20
|
+
def run(self, changes, store, config) -> list[Finding]:
|
|
21
|
+
# TODO(M2): implement the algorithm above. Batch blame per file to stay fast.
|
|
22
|
+
return []
|
blot/classify.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Decide what kind of commit something is. Most of Blot's precision comes from here.
|
|
2
|
+
|
|
3
|
+
Fix severity:
|
|
4
|
+
0 not a fix (feature, docs, chore)
|
|
5
|
+
1 routine fix ("fix typo", "fix lint", a bare "fix stuff")
|
|
6
|
+
2 load-bearing fix: a fix verb pointing at an issue ("fixes #12"), or a commit that names
|
|
7
|
+
a real defect (crash, leak, race, regression, CVE, injection, deadlock, data loss, ...)
|
|
8
|
+
|
|
9
|
+
`is_refactor` flags commits that move code without changing what it does (file splits,
|
|
10
|
+
renames, mass reformatting). When blame lands on one of these, Blot traces further
|
|
11
|
+
back. That is how it finds the 2020 Flask fix behind the 2023 "Split the App..." commit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CommitInfo:
|
|
22
|
+
sha: str
|
|
23
|
+
subject: str
|
|
24
|
+
body: str
|
|
25
|
+
files_changed: int
|
|
26
|
+
lines_added: int
|
|
27
|
+
lines_removed: int
|
|
28
|
+
renames: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_ISSUE = r"(?:#\d+|gh-\d+|[A-Z][A-Z0-9]+-\d+)"
|
|
32
|
+
# "fixes #12" means the commit is the fix; "closes #12" is often just housekeeping.
|
|
33
|
+
_FIX_ISSUE = re.compile(rf"\b(?:fix(?:e[sd])?|resolve[sd]?)\b[:\s]+{_ISSUE}", re.IGNORECASE)
|
|
34
|
+
_ANY_ISSUE = re.compile(rf"(?<![\w/]){_ISSUE}\b")
|
|
35
|
+
_FIX_WORD = re.compile(r"\b(?:fix(?:e[sd]|ing)?|bug(?:fix)?|hotfix|patch(?:e[sd])?|repair(?:e[sd])?|workaround)\b", re.IGNORECASE)
|
|
36
|
+
# Strong defect words make a commit load-bearing on their own; weak ones need a fix verb too.
|
|
37
|
+
_DEFECT = re.compile(
|
|
38
|
+
r"\b(?:crash(?:e[sd])?|segfault|panic|leak(?:s|ing)?|race(?: condition)?|deadlock|regression|"
|
|
39
|
+
r"cve-\d{4}-\d+|vulnerab\w*|injection|xss|csrf|ssrf|overflow|underflow|corrupt\w*|"
|
|
40
|
+
r"data loss|infinite loop|null pointer|use-after-free|reference cycle|off-by-one)\b",
|
|
41
|
+
re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
_WEAK_DEFECT = re.compile(r"\b(?:hang(?:s|ing)?|timeout|broken|incorrect(?:ly)?|wrong|security)\b", re.IGNORECASE)
|
|
44
|
+
_ROUTINE = re.compile(
|
|
45
|
+
r"\b(?:typo|typos|lint|linter|flake8|pylint|mypy|ruff|eslint|format(?:ting)?|style|"
|
|
46
|
+
r"whitespace|spelling|docs?|document\w*|docstring|comment|readme|changelog|warning|ci|build|"
|
|
47
|
+
r"examples?|tutorial|tests?|testing|translation|note|mention\w*|advice|wording|sentence|links?|gramma\w*)\b",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
)
|
|
50
|
+
_REFACTOR = re.compile(
|
|
51
|
+
r"\b(?:move[sd]?|moving|split|splits|splitting|rename[sd]?|renaming|reorganiz\w*|"
|
|
52
|
+
r"restructur\w*|refactor\w*|reformat\w*|extract(?:ed)?|black|prettier|isort|clang-format)\b",
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fix_severity(commit: CommitInfo, issue_labels: list[str] | None = None) -> int:
|
|
58
|
+
subject, text = commit.subject, f"{commit.subject}\n{commit.body}"
|
|
59
|
+
if issue_labels and any("bug" in label.lower() or "security" in label.lower() for label in issue_labels):
|
|
60
|
+
return 2
|
|
61
|
+
fixish = bool(_FIX_WORD.search(subject) or _FIX_ISSUE.search(text))
|
|
62
|
+
if _ROUTINE.search(subject): # docs/tests/style work, even when it mentions a defect word
|
|
63
|
+
return 1 if fixish else 0
|
|
64
|
+
if _DEFECT.search(subject):
|
|
65
|
+
return 2
|
|
66
|
+
if _FIX_ISSUE.search(text) or (_WEAK_DEFECT.search(subject) and _FIX_WORD.search(subject)):
|
|
67
|
+
return 2
|
|
68
|
+
return 1 if fixish else 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_refactor(commit: CommitInfo) -> bool:
|
|
72
|
+
if _FIX_WORD.search(commit.subject) or _DEFECT.search(commit.subject):
|
|
73
|
+
return False
|
|
74
|
+
if _REFACTOR.search(commit.subject):
|
|
75
|
+
return True
|
|
76
|
+
if commit.files_changed and commit.renames * 2 >= commit.files_changed:
|
|
77
|
+
return True
|
|
78
|
+
big = min(commit.lines_added, commit.lines_removed) >= 50
|
|
79
|
+
balanced = abs(commit.lines_added - commit.lines_removed) <= 0.1 * max(commit.lines_added, commit.lines_removed, 1)
|
|
80
|
+
return big and balanced
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def linked_issues(commit: CommitInfo) -> list[str]:
|
|
84
|
+
"""Issue references in the message, in order, without duplicates."""
|
|
85
|
+
seen: dict[str, None] = {}
|
|
86
|
+
for ref in _ANY_ISSUE.findall(f"{commit.subject}\n{commit.body}"):
|
|
87
|
+
seen.setdefault(ref, None)
|
|
88
|
+
return list(seen)
|
blot/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""blot: history-aware pre-commit checks.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
index build or refresh the local history index (.git/blot.db)
|
|
5
|
+
check run all checks against staged changes (pre-commit) or a commit range
|
|
6
|
+
why show the history behind a line: fix commits, PR, linked issues
|
|
7
|
+
install-hook add blot to .git/hooks/pre-commit
|
|
8
|
+
bench score the checks against bench/cases.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from blot import __version__
|
|
17
|
+
from blot.checks import all_checks
|
|
18
|
+
from blot.config import load_config
|
|
19
|
+
from blot.report import render
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cmd_index(args: argparse.Namespace) -> int:
|
|
23
|
+
from blot.index import build_index
|
|
24
|
+
|
|
25
|
+
result = build_index(args.repo, full=args.full, github=args.github)
|
|
26
|
+
if not args.quiet:
|
|
27
|
+
kind = "full" if result.full else "incremental"
|
|
28
|
+
print(f"blot: indexed {result.commits} commits ({result.fixes} load-bearing fixes), "
|
|
29
|
+
f"{kind}, in {result.seconds:.1f}s")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
34
|
+
from blot.diff import read_changes
|
|
35
|
+
from blot.store import Store
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
config = load_config(args.repo)
|
|
39
|
+
store = Store.open(args.repo)
|
|
40
|
+
if store is None:
|
|
41
|
+
# Never break a commit because the index is missing; just say how to fix it.
|
|
42
|
+
print("blot: no index yet, run `blot index` (commit allowed)", file=sys.stderr)
|
|
43
|
+
return 0
|
|
44
|
+
changes = read_changes(args.repo, rev_range=args.range)
|
|
45
|
+
findings = [f for check in all_checks(config) for f in check.run(changes, store, config)]
|
|
46
|
+
except Exception as exc: # Blot's own failures must never block a commit
|
|
47
|
+
print(f"blot: internal error, skipping checks ({exc.__class__.__name__}: {exc})", file=sys.stderr)
|
|
48
|
+
return 0
|
|
49
|
+
print(render(findings, fmt=args.format), end="")
|
|
50
|
+
return 1 if (args.strict and findings) else 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cmd_why(args: argparse.Namespace) -> int:
|
|
54
|
+
raise NotImplementedError("TODO(M3): trace a line to its explaining commits")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_install_hook(args: argparse.Namespace) -> int:
|
|
58
|
+
from blot import hooks
|
|
59
|
+
|
|
60
|
+
if args.uninstall:
|
|
61
|
+
print(f"blot: pre-commit hook {hooks.uninstall(args.repo)}")
|
|
62
|
+
return 0
|
|
63
|
+
status = hooks.install(args.repo)
|
|
64
|
+
print(f"blot: pre-commit hook {status} ({hooks.hook_path(args.repo)})")
|
|
65
|
+
if status != "already installed":
|
|
66
|
+
print("blot: run `blot index` once so the checks have history to work with")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_bench(args: argparse.Namespace) -> int:
|
|
71
|
+
raise NotImplementedError("TODO(M4): run bench/cases.json and print precision/recall per check")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
75
|
+
parser = argparse.ArgumentParser(prog="blot", description="History-aware pre-commit checks.")
|
|
76
|
+
parser.add_argument("--version", action="version", version=f"blot {__version__}")
|
|
77
|
+
parser.add_argument("--repo", default=".", help="path to the git repository (default: .)")
|
|
78
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
79
|
+
|
|
80
|
+
p = sub.add_parser("index", help="build or refresh the local history index")
|
|
81
|
+
p.add_argument("--full", action="store_true", help="rebuild from scratch instead of incrementally")
|
|
82
|
+
p.add_argument("--github", action="store_true", help="add PR/issue data via the gh CLI (milestone M3)")
|
|
83
|
+
p.add_argument("--quiet", action="store_true", help="print nothing on success (for git hooks)")
|
|
84
|
+
p.set_defaults(func=_cmd_index)
|
|
85
|
+
|
|
86
|
+
p = sub.add_parser("check", help="run history checks on staged changes or a commit range")
|
|
87
|
+
p.add_argument("--staged", action="store_true", help="check staged changes (the default)")
|
|
88
|
+
p.add_argument("--range", metavar="A..B", help="check a commit range instead, e.g. origin/main..HEAD")
|
|
89
|
+
p.add_argument("--strict", action="store_true", help="exit 1 when there are findings (blocks the commit)")
|
|
90
|
+
p.add_argument("--format", choices=["text", "json"], default="text")
|
|
91
|
+
p.set_defaults(func=_cmd_check)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("why", help="show the history behind a line")
|
|
94
|
+
p.add_argument("location", metavar="FILE:LINE")
|
|
95
|
+
p.set_defaults(func=_cmd_why)
|
|
96
|
+
|
|
97
|
+
p = sub.add_parser("install-hook", help="add blot to .git/hooks/pre-commit")
|
|
98
|
+
p.add_argument("--uninstall", action="store_true", help="remove blot's lines from the hook")
|
|
99
|
+
p.set_defaults(func=_cmd_install_hook)
|
|
100
|
+
|
|
101
|
+
p = sub.add_parser("bench", help="score checks against bench/cases.json")
|
|
102
|
+
p.set_defaults(func=_cmd_bench)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
args = build_parser().parse_args(argv)
|
|
108
|
+
return args.func(args)
|
blot/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Settings, read from `[tool.blot]` in pyproject.toml and/or a `.blot.toml` file.
|
|
2
|
+
|
|
3
|
+
`.blot.toml` wins over pyproject.toml. Keys may use dashes or underscores:
|
|
4
|
+
|
|
5
|
+
[tool.blot]
|
|
6
|
+
min-severity = 2
|
|
7
|
+
cochange-min-confidence = 0.8
|
|
8
|
+
disabled-checks = ["forgot-related-file"]
|
|
9
|
+
ignore-paths = ["docs/*", "*.lock"]
|
|
10
|
+
|
|
11
|
+
TOML parsing needs Python 3.11+ (stdlib `tomllib`). On 3.10 the defaults are used.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass, field, fields
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Config:
|
|
23
|
+
# removed-fix / re-added-bug: only warn for commits classified at or above this severity
|
|
24
|
+
min_severity: int = 2
|
|
25
|
+
# forgot-related-file: confidence that B changes when A changes, and minimum joint commits
|
|
26
|
+
cochange_min_confidence: float = 0.8
|
|
27
|
+
cochange_min_support: int = 5
|
|
28
|
+
# skip commits touching more files than this when mining co-change (mass reformat, renames)
|
|
29
|
+
cochange_max_files_per_commit: int = 30
|
|
30
|
+
disabled_checks: list[str] = field(default_factory=list)
|
|
31
|
+
ignore_paths: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_toml(path: str) -> dict:
|
|
35
|
+
try:
|
|
36
|
+
import tomllib
|
|
37
|
+
except ModuleNotFoundError: # Python 3.10
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
with open(path, "rb") as f:
|
|
41
|
+
return tomllib.load(f)
|
|
42
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
43
|
+
print(f"blot: ignoring {os.path.basename(path)}: {exc}", file=sys.stderr)
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(repo: str) -> Config:
|
|
48
|
+
raw: dict = {}
|
|
49
|
+
pyproject = os.path.join(repo, "pyproject.toml")
|
|
50
|
+
if os.path.exists(pyproject):
|
|
51
|
+
raw.update(_read_toml(pyproject).get("tool", {}).get("blot", {}))
|
|
52
|
+
own = os.path.join(repo, ".blot.toml")
|
|
53
|
+
if os.path.exists(own):
|
|
54
|
+
raw.update(_read_toml(own))
|
|
55
|
+
|
|
56
|
+
config = Config()
|
|
57
|
+
known = {f.name: f for f in fields(Config)}
|
|
58
|
+
for key, value in raw.items():
|
|
59
|
+
name = key.replace("-", "_")
|
|
60
|
+
if name not in known:
|
|
61
|
+
print(f"blot: unknown config key {key!r} (ignored)", file=sys.stderr)
|
|
62
|
+
continue
|
|
63
|
+
default = getattr(config, name)
|
|
64
|
+
expected = type(default)
|
|
65
|
+
if expected is float and isinstance(value, int):
|
|
66
|
+
value = float(value)
|
|
67
|
+
if not isinstance(value, expected) or (expected is int and isinstance(value, bool)):
|
|
68
|
+
print(f"blot: config key {key!r} should be {expected.__name__} (ignored)", file=sys.stderr)
|
|
69
|
+
continue
|
|
70
|
+
setattr(config, name, value)
|
|
71
|
+
return config
|
blot/diff.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Turn `git diff` output into per-file added and removed lines with line numbers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from blot.git import run_git
|
|
10
|
+
|
|
11
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
12
|
+
_TRAILING_COMMENT = re.compile(r"\s+(?:#|//)\s.*$")
|
|
13
|
+
_TRIVIAL = re.compile(r"^(?:[\s{}()\[\];,.:]*|(?:import|from|#include|using|package|require)\b.*|(?:return|pass|break|continue|else|end|try|finally)\b\W*)$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_line(text: str) -> str:
|
|
17
|
+
"""Collapse whitespace and drop trailing comments so cosmetic edits still match."""
|
|
18
|
+
return " ".join(_TRAILING_COMMENT.sub("", text).split())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_trivial_line(normalized: str) -> bool:
|
|
22
|
+
"""Lines too generic to mean anything on their own: brackets, imports, bare keywords."""
|
|
23
|
+
return len(normalized) < 12 or bool(_TRIVIAL.match(normalized))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def line_hash(normalized: str) -> str:
|
|
27
|
+
return hashlib.blake2b(normalized.encode("utf-8"), digest_size=8).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FileChange:
|
|
32
|
+
path: str # path after the change (old path for deleted files)
|
|
33
|
+
old_path: str | None # path before the change (None for new files)
|
|
34
|
+
removed: list[tuple[int, str]] = field(default_factory=list) # (old line number, text)
|
|
35
|
+
added: list[tuple[int, str]] = field(default_factory=list) # (new line number, text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_unified_diff(text: str) -> list[FileChange]:
|
|
39
|
+
"""Parse `git diff -U0` output. Binary files and mode-only changes produce no lines."""
|
|
40
|
+
changes: list[FileChange] = []
|
|
41
|
+
current: FileChange | None = None
|
|
42
|
+
old_path: str | None = None
|
|
43
|
+
old_no = new_no = 0
|
|
44
|
+
for line in text.splitlines():
|
|
45
|
+
if line.startswith("--- "):
|
|
46
|
+
old_path = None if line == "--- /dev/null" else line[6:] # strip "--- a/"
|
|
47
|
+
elif line.startswith("+++ "):
|
|
48
|
+
new_path = old_path if line == "+++ /dev/null" else line[6:] # strip "+++ b/"
|
|
49
|
+
current = FileChange(path=new_path or "", old_path=old_path)
|
|
50
|
+
changes.append(current)
|
|
51
|
+
elif current is None:
|
|
52
|
+
continue
|
|
53
|
+
elif m := _HUNK.match(line):
|
|
54
|
+
old_no, new_no = int(m.group(1)), int(m.group(3))
|
|
55
|
+
elif line.startswith("-"):
|
|
56
|
+
current.removed.append((old_no, line[1:]))
|
|
57
|
+
old_no += 1
|
|
58
|
+
elif line.startswith("+"):
|
|
59
|
+
current.added.append((new_no, line[1:]))
|
|
60
|
+
new_no += 1
|
|
61
|
+
return changes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_changes(repo: str, rev_range: str | None = None) -> list[FileChange]:
|
|
65
|
+
"""Staged changes by default, or the changes in a commit range like `origin/main..HEAD`."""
|
|
66
|
+
args = ["diff", "-U0", "--no-color", "--no-ext-diff", "-M"]
|
|
67
|
+
args.append(rev_range if rev_range else "--cached")
|
|
68
|
+
return parse_unified_diff(run_git(repo, *args))
|
blot/git.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Thin wrapper around the git CLI. All git access goes through here so tests can fake it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_git(repo: str, *args: str) -> str:
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["git", "-C", repo, *args],
|
|
11
|
+
capture_output=True,
|
|
12
|
+
text=True,
|
|
13
|
+
encoding="utf-8",
|
|
14
|
+
errors="replace",
|
|
15
|
+
check=True,
|
|
16
|
+
)
|
|
17
|
+
return result.stdout
|
blot/hooks.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Install and remove Blot's git pre-commit hook without clobbering existing hooks.
|
|
2
|
+
|
|
3
|
+
Blot's lines live between two markers, so we can append to a hook someone else wrote and
|
|
4
|
+
later remove exactly what we added. The hook calls this Python interpreter by absolute path,
|
|
5
|
+
so it works from IDEs whose PATH lacks your virtualenv, and it skips itself (never blocks a
|
|
6
|
+
commit) if that interpreter disappears.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from blot.git import run_git
|
|
16
|
+
|
|
17
|
+
BEGIN, END = "# >>> blot >>>", "# <<< blot <<<"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hook_path(repo: str, name: str = "pre-commit") -> str:
|
|
21
|
+
path = run_git(repo, "rev-parse", "--git-path", f"hooks/{name}").strip()
|
|
22
|
+
return path if os.path.isabs(path) else os.path.join(repo, path)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _block(python: str) -> str:
|
|
26
|
+
py = python.replace("\\", "/")
|
|
27
|
+
return (
|
|
28
|
+
f"{BEGIN}\n"
|
|
29
|
+
f'if [ -x "{py}" ]; then "{py}" -m blot check || exit $?; fi\n'
|
|
30
|
+
f"{END}\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def install(repo: str, python: str | None = None) -> str:
|
|
35
|
+
"""Add Blot to the pre-commit hook. Returns 'installed', 'appended' or 'already installed'."""
|
|
36
|
+
path = hook_path(repo)
|
|
37
|
+
block = _block(python or sys.executable)
|
|
38
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
39
|
+
if os.path.exists(path):
|
|
40
|
+
with open(path, encoding="utf-8") as f:
|
|
41
|
+
current = f.read()
|
|
42
|
+
if BEGIN in current:
|
|
43
|
+
return "already installed"
|
|
44
|
+
with open(path, "a", encoding="utf-8", newline="\n") as f:
|
|
45
|
+
f.write(("" if current.endswith("\n") else "\n") + block)
|
|
46
|
+
result = "appended"
|
|
47
|
+
else:
|
|
48
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
49
|
+
f.write("#!/bin/sh\n" + block)
|
|
50
|
+
result = "installed"
|
|
51
|
+
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def uninstall(repo: str) -> str:
|
|
56
|
+
"""Remove Blot's block. Deletes the hook file only if nothing else is left in it."""
|
|
57
|
+
path = hook_path(repo)
|
|
58
|
+
if not os.path.exists(path):
|
|
59
|
+
return "not installed"
|
|
60
|
+
with open(path, encoding="utf-8") as f:
|
|
61
|
+
lines = f.read().splitlines(keepends=True)
|
|
62
|
+
if not any(line.strip() == BEGIN for line in lines):
|
|
63
|
+
return "not installed"
|
|
64
|
+
kept, inside = [], False
|
|
65
|
+
for line in lines:
|
|
66
|
+
if line.strip() == BEGIN:
|
|
67
|
+
inside = True
|
|
68
|
+
elif line.strip() == END:
|
|
69
|
+
inside = False
|
|
70
|
+
elif not inside:
|
|
71
|
+
kept.append(line)
|
|
72
|
+
if "".join(kept).strip() in ("", "#!/bin/sh"):
|
|
73
|
+
os.remove(path)
|
|
74
|
+
else:
|
|
75
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
76
|
+
f.writelines(kept)
|
|
77
|
+
return "removed"
|
blot/index.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Build or incrementally refresh the history index.
|
|
2
|
+
|
|
3
|
+
Steps:
|
|
4
|
+
1. walk `git log --numstat -M` from the last indexed sha to HEAD (no merges)
|
|
5
|
+
2. classify each commit (classify.py), store severity and refactor flags
|
|
6
|
+
3. for load-bearing fixes, hash the lines they removed -> fix_removed_lines
|
|
7
|
+
4. count file pairs changed together (skip huge commits) -> file_commits / cochange
|
|
8
|
+
Targets: under 60 s for 50k commits on a laptop; incremental refresh under 1 s.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from blot.classify import CommitInfo, fix_severity, is_refactor, linked_issues
|
|
19
|
+
from blot.config import Config, load_config
|
|
20
|
+
from blot.diff import is_trivial_line, line_hash, normalize_line, parse_unified_diff
|
|
21
|
+
from blot.git import run_git
|
|
22
|
+
from blot.store import CommitRow, Store
|
|
23
|
+
|
|
24
|
+
_RECORD, _FIELD = "\x1e", "\x1f"
|
|
25
|
+
_BRACE_RENAME = re.compile(r"\{([^{}]*) => ([^{}]*)\}")
|
|
26
|
+
_SHOW_BATCH = 200
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class IndexResult:
|
|
31
|
+
commits: int
|
|
32
|
+
fixes: int
|
|
33
|
+
seconds: float
|
|
34
|
+
full: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _new_path(numstat_path: str) -> tuple[str, bool]:
|
|
38
|
+
"""Resolve rename notation (`a/{x => y}/b` or `old => new`) to the new path."""
|
|
39
|
+
if "=>" not in numstat_path:
|
|
40
|
+
return numstat_path, False
|
|
41
|
+
if "{" in numstat_path:
|
|
42
|
+
path = _BRACE_RENAME.sub(lambda m: m.group(2), numstat_path)
|
|
43
|
+
return path.replace("//", "/"), True
|
|
44
|
+
return numstat_path.split(" => ", 1)[1], True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_log(text: str) -> list[tuple[CommitInfo, str, list[str]]]:
|
|
48
|
+
"""Parse our custom `git log --numstat` format into (info, date, paths)."""
|
|
49
|
+
commits = []
|
|
50
|
+
for record in text.split(_RECORD)[1:]:
|
|
51
|
+
sha, date, subject, rest = record.split(_FIELD, 3)
|
|
52
|
+
body, _, numstat = rest.partition(_FIELD)
|
|
53
|
+
added = removed = renames = 0
|
|
54
|
+
paths: list[str] = []
|
|
55
|
+
for line in numstat.strip().splitlines():
|
|
56
|
+
parts = line.split("\t")
|
|
57
|
+
if len(parts) != 3:
|
|
58
|
+
continue
|
|
59
|
+
a, r, raw_path = parts
|
|
60
|
+
path, renamed = _new_path(raw_path)
|
|
61
|
+
renames += renamed
|
|
62
|
+
added += int(a) if a.isdigit() else 0
|
|
63
|
+
removed += int(r) if r.isdigit() else 0
|
|
64
|
+
paths.append(path)
|
|
65
|
+
info = CommitInfo(sha, subject, body.strip(), len(paths), added, removed, renames)
|
|
66
|
+
commits.append((info, date, paths))
|
|
67
|
+
return commits
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_ancestor(repo: str, older: str, newer: str) -> bool:
|
|
71
|
+
try:
|
|
72
|
+
subprocess.run(
|
|
73
|
+
["git", "-C", repo, "merge-base", "--is-ancestor", older, newer],
|
|
74
|
+
capture_output=True, check=True,
|
|
75
|
+
)
|
|
76
|
+
return True
|
|
77
|
+
except subprocess.CalledProcessError:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _head(repo: str) -> str | None:
|
|
82
|
+
try:
|
|
83
|
+
return run_git(repo, "rev-parse", "--verify", "-q", "HEAD").strip() or None
|
|
84
|
+
except subprocess.CalledProcessError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _removed_lines(repo: str, shas: list[str]) -> list[tuple[str, str, str]]:
|
|
89
|
+
"""(line_hash, path, sha) for every meaningful line the given commits deleted."""
|
|
90
|
+
rows: list[tuple[str, str, str]] = []
|
|
91
|
+
for i in range(0, len(shas), _SHOW_BATCH):
|
|
92
|
+
batch = shas[i : i + _SHOW_BATCH]
|
|
93
|
+
out = run_git(repo, "show", f"--format={_RECORD}%H", "-U0", "--no-color", "--no-ext-diff", "-M", *batch)
|
|
94
|
+
for chunk in out.split(_RECORD)[1:]:
|
|
95
|
+
sha, _, diff_text = chunk.partition("\n")
|
|
96
|
+
for change in parse_unified_diff(diff_text):
|
|
97
|
+
path = change.old_path or change.path
|
|
98
|
+
for _, text in change.removed:
|
|
99
|
+
norm = normalize_line(text)
|
|
100
|
+
if not is_trivial_line(norm):
|
|
101
|
+
rows.append((line_hash(norm), path, sha.strip()))
|
|
102
|
+
return rows
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_index(repo: str, full: bool = False, github: bool = False, config: Config | None = None) -> IndexResult:
|
|
106
|
+
start = time.perf_counter()
|
|
107
|
+
config = config or load_config(repo)
|
|
108
|
+
head = _head(repo)
|
|
109
|
+
|
|
110
|
+
store = Store.open(repo)
|
|
111
|
+
last = store.get_meta("last_sha") if store else None
|
|
112
|
+
if store:
|
|
113
|
+
store.close()
|
|
114
|
+
rebuild = full or not last or (head is not None and not _is_ancestor(repo, last, head))
|
|
115
|
+
store = Store.create(repo, fresh=rebuild)
|
|
116
|
+
|
|
117
|
+
if head is None or (not rebuild and last == head):
|
|
118
|
+
store.set_meta("last_sha", head or "")
|
|
119
|
+
store.commit()
|
|
120
|
+
store.close()
|
|
121
|
+
return IndexResult(0, 0, time.perf_counter() - start, rebuild)
|
|
122
|
+
|
|
123
|
+
rev = head if rebuild else f"{last}..{head}"
|
|
124
|
+
fmt = f"--format={_RECORD}%H{_FIELD}%aI{_FIELD}%s{_FIELD}%b{_FIELD}"
|
|
125
|
+
log = run_git(repo, "log", "--no-merges", "-M", "--numstat", fmt, rev)
|
|
126
|
+
parsed = _parse_log(log)
|
|
127
|
+
|
|
128
|
+
fix_shas = []
|
|
129
|
+
for info, date, paths in parsed:
|
|
130
|
+
severity = fix_severity(info)
|
|
131
|
+
row = CommitRow(info.sha, date, info.subject, severity, is_refactor(info), info.files_changed, linked_issues(info))
|
|
132
|
+
store.add_commit(row)
|
|
133
|
+
if severity >= 2:
|
|
134
|
+
fix_shas.append(info.sha)
|
|
135
|
+
if 0 < len(paths) <= config.cochange_max_files_per_commit:
|
|
136
|
+
store.add_cochange(paths)
|
|
137
|
+
|
|
138
|
+
store.add_fix_removed_lines(_removed_lines(repo, fix_shas))
|
|
139
|
+
store.set_meta("last_sha", head)
|
|
140
|
+
store.commit()
|
|
141
|
+
store.close()
|
|
142
|
+
if github:
|
|
143
|
+
print("blot: --github enrichment arrives in milestone M3; indexed git history only.")
|
|
144
|
+
return IndexResult(len(parsed), len(fix_shas), time.perf_counter() - start, rebuild)
|
blot/report.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Render findings for people (text) or tools (json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
|
|
8
|
+
from blot.checks.base import Finding
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render(findings: list[Finding], fmt: str = "text") -> str:
|
|
12
|
+
if fmt == "json":
|
|
13
|
+
return json.dumps([asdict(f) for f in findings], indent=2) + "\n"
|
|
14
|
+
if not findings:
|
|
15
|
+
return ""
|
|
16
|
+
plural = "s" if len(findings) != 1 else ""
|
|
17
|
+
lines = [f"blot: {len(findings)} warning{plural}", ""]
|
|
18
|
+
for f in findings:
|
|
19
|
+
where = f"{f.path}:{f.line}" if f.line else f.path
|
|
20
|
+
lines.append(f" ! {f.check} {where}")
|
|
21
|
+
lines.extend(f" {text}" for text in f.message.splitlines())
|
|
22
|
+
lines.extend(f" {ref}" for ref in f.evidence)
|
|
23
|
+
lines.append("")
|
|
24
|
+
return "\n".join(lines) + "\n"
|
blot/store.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Local SQLite index stored at .git/blot.db. Never committed, never leaves the machine.
|
|
2
|
+
|
|
3
|
+
Tables:
|
|
4
|
+
meta(key, value) schema version, last indexed sha
|
|
5
|
+
commits(sha, date, subject, severity, is_refactor, files, issues)
|
|
6
|
+
fix_removed_lines(line_hash, path, sha) normalized lines deleted by load-bearing fixes
|
|
7
|
+
file_commits(path, count) how many indexed commits touched each file
|
|
8
|
+
cochange(path_a, path_b, support) commits touching both files (path_a < path_b)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from blot.git import run_git
|
|
18
|
+
|
|
19
|
+
SCHEMA_VERSION = 1
|
|
20
|
+
|
|
21
|
+
_SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
24
|
+
sha TEXT PRIMARY KEY, date TEXT, subject TEXT,
|
|
25
|
+
severity INTEGER, is_refactor INTEGER, files INTEGER, issues TEXT
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS fix_removed_lines (line_hash TEXT, path TEXT, sha TEXT);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS fix_removed_lines_hash ON fix_removed_lines (line_hash);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS file_commits (path TEXT PRIMARY KEY, count INTEGER);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS cochange (
|
|
31
|
+
path_a TEXT, path_b TEXT, support INTEGER, PRIMARY KEY (path_a, path_b)
|
|
32
|
+
);
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CommitRow:
|
|
38
|
+
sha: str
|
|
39
|
+
date: str
|
|
40
|
+
subject: str
|
|
41
|
+
severity: int
|
|
42
|
+
is_refactor: bool
|
|
43
|
+
files: int
|
|
44
|
+
issues: list[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Store:
|
|
48
|
+
def __init__(self, conn: sqlite3.Connection):
|
|
49
|
+
self.conn = conn
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def db_path(repo: str) -> str:
|
|
53
|
+
git_dir = run_git(repo, "rev-parse", "--absolute-git-dir").strip()
|
|
54
|
+
return os.path.join(git_dir, "blot.db")
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def open(cls, repo: str) -> Store | None:
|
|
58
|
+
"""Return the index, or None if `blot index` has never been run (or it is outdated)."""
|
|
59
|
+
path = cls.db_path(repo)
|
|
60
|
+
if not os.path.exists(path):
|
|
61
|
+
return None
|
|
62
|
+
store = cls(sqlite3.connect(path))
|
|
63
|
+
if store.get_meta("schema_version") != str(SCHEMA_VERSION):
|
|
64
|
+
store.close()
|
|
65
|
+
return None
|
|
66
|
+
return store
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def create(cls, repo: str, fresh: bool = False) -> Store:
|
|
70
|
+
path = cls.db_path(repo)
|
|
71
|
+
if fresh and os.path.exists(path):
|
|
72
|
+
os.remove(path)
|
|
73
|
+
store = cls(sqlite3.connect(path))
|
|
74
|
+
if store._table_exists("meta") and store.get_meta("schema_version") != str(SCHEMA_VERSION):
|
|
75
|
+
store.close()
|
|
76
|
+
os.remove(path)
|
|
77
|
+
store = cls(sqlite3.connect(path))
|
|
78
|
+
store.conn.executescript(_SCHEMA)
|
|
79
|
+
store.set_meta("schema_version", str(SCHEMA_VERSION))
|
|
80
|
+
return store
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self.conn.close()
|
|
84
|
+
|
|
85
|
+
def _table_exists(self, name: str) -> bool:
|
|
86
|
+
row = self.conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone()
|
|
87
|
+
return row is not None
|
|
88
|
+
|
|
89
|
+
# --- meta ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def get_meta(self, key: str) -> str | None:
|
|
92
|
+
try:
|
|
93
|
+
row = self.conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
|
|
94
|
+
except sqlite3.OperationalError:
|
|
95
|
+
return None
|
|
96
|
+
return row[0] if row else None
|
|
97
|
+
|
|
98
|
+
def set_meta(self, key: str, value: str) -> None:
|
|
99
|
+
self.conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value))
|
|
100
|
+
|
|
101
|
+
# --- writes (used by index.py) ------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def add_commit(self, row: CommitRow) -> None:
|
|
104
|
+
self.conn.execute(
|
|
105
|
+
"INSERT OR REPLACE INTO commits VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
106
|
+
(row.sha, row.date, row.subject, row.severity, int(row.is_refactor), row.files, ",".join(row.issues)),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def add_fix_removed_lines(self, rows: list[tuple[str, str, str]]) -> None:
|
|
110
|
+
self.conn.executemany("INSERT INTO fix_removed_lines VALUES (?, ?, ?)", rows)
|
|
111
|
+
|
|
112
|
+
def add_cochange(self, paths: list[str]) -> None:
|
|
113
|
+
"""Record one commit touching `paths` (already filtered for huge commits)."""
|
|
114
|
+
self.conn.executemany(
|
|
115
|
+
"INSERT INTO file_commits VALUES (?, 1) ON CONFLICT(path) DO UPDATE SET count = count + 1",
|
|
116
|
+
[(p,) for p in paths],
|
|
117
|
+
)
|
|
118
|
+
ordered = sorted(set(paths))
|
|
119
|
+
pairs = [(a, b) for i, a in enumerate(ordered) for b in ordered[i + 1 :]]
|
|
120
|
+
self.conn.executemany(
|
|
121
|
+
"INSERT INTO cochange VALUES (?, ?, 1) ON CONFLICT(path_a, path_b) DO UPDATE SET support = support + 1",
|
|
122
|
+
pairs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def commit(self) -> None:
|
|
126
|
+
self.conn.commit()
|
|
127
|
+
|
|
128
|
+
# --- reads (used by checks) ---------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
def get_commit(self, sha: str) -> CommitRow | None:
|
|
131
|
+
row = self.conn.execute("SELECT * FROM commits WHERE sha=?", (sha,)).fetchone()
|
|
132
|
+
if row is None:
|
|
133
|
+
return None
|
|
134
|
+
sha, date, subject, severity, refactor, files, issues = row
|
|
135
|
+
return CommitRow(sha, date, subject, severity, bool(refactor), files, [i for i in issues.split(",") if i])
|
|
136
|
+
|
|
137
|
+
def fixes_that_removed(self, line_hash: str) -> list[tuple[str, str]]:
|
|
138
|
+
"""(path, sha) of load-bearing fixes that deleted a line with this hash."""
|
|
139
|
+
return self.conn.execute("SELECT path, sha FROM fix_removed_lines WHERE line_hash=?", (line_hash,)).fetchall()
|
|
140
|
+
|
|
141
|
+
def partners(self, path: str, min_support: int, min_confidence: float) -> list[tuple[str, int, float]]:
|
|
142
|
+
"""Files that usually change with `path`: (partner, support, confidence path→partner)."""
|
|
143
|
+
row = self.conn.execute("SELECT count FROM file_commits WHERE path=?", (path,)).fetchone()
|
|
144
|
+
if not row or row[0] == 0:
|
|
145
|
+
return []
|
|
146
|
+
total = row[0]
|
|
147
|
+
rows = self.conn.execute(
|
|
148
|
+
"SELECT CASE WHEN path_a=? THEN path_b ELSE path_a END, support FROM cochange "
|
|
149
|
+
"WHERE (path_a=? OR path_b=?) AND support >= ?",
|
|
150
|
+
(path, path, path, min_support),
|
|
151
|
+
).fetchall()
|
|
152
|
+
result = [(p, s, s / total) for p, s in rows if s / total >= min_confidence]
|
|
153
|
+
return sorted(result, key=lambda r: (-r[2], -r[1], r[0]))
|
|
154
|
+
|
|
155
|
+
def stats(self) -> dict[str, int]:
|
|
156
|
+
q = lambda sql: self.conn.execute(sql).fetchone()[0] # noqa: E731
|
|
157
|
+
return {
|
|
158
|
+
"commits": q("SELECT COUNT(*) FROM commits"),
|
|
159
|
+
"fixes": q("SELECT COUNT(*) FROM commits WHERE severity >= 2"),
|
|
160
|
+
"refactors": q("SELECT COUNT(*) FROM commits WHERE is_refactor = 1"),
|
|
161
|
+
"fix_lines": q("SELECT COUNT(*) FROM fix_removed_lines"),
|
|
162
|
+
"pairs": q("SELECT COUNT(*) FROM cochange"),
|
|
163
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: blot-git
|
|
3
|
+
Version: 0.0.1
|
|
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
|
+
Project-URL: Homepage, https://github.com/Sym-jay/Blot
|
|
6
|
+
Project-URL: Issues, https://github.com/Sym-jay/Blot/issues
|
|
7
|
+
Author: Shyam
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: code-review,developer-tools,git,git-history,pre-commit
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Provides-Extra: github
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Blot
|
|
26
|
+
|
|
27
|
+
**A pre-commit check that has read your project's history.**
|
|
28
|
+
|
|
29
|
+
Blot warns you *before you commit* when your change:
|
|
30
|
+
|
|
31
|
+
- **removes a fix**: deletes or edits code that was added to fix a bug
|
|
32
|
+
- **re-adds a bug**: brings back a line that a bug fix deliberately removed
|
|
33
|
+
- **forgets a related file**: changes `A` but not `B`, when the two almost always change together
|
|
34
|
+
|
|
35
|
+
It runs locally, needs no account or AI, and stays quiet unless the history gives it a real reason to speak.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install blot-git
|
|
39
|
+
cd your-repo
|
|
40
|
+
blot index # one-time, builds .git/blot.db
|
|
41
|
+
blot install-hook # adds Blot to your pre-commit hook
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Status:** pre-alpha. Indexing and the git hook work; the three checks arrive in the next milestone.
|
|
45
|
+
|
|
46
|
+
Meet Sora the slime, read the docs, or contribute on GitHub: https://github.com/Sym-jay/Blot
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
blot/__init__.py,sha256=SsqM85lr7OEfS7nXudvei3DFahDzZ5Glb4FYpzI0qDY,68
|
|
2
|
+
blot/__main__.py,sha256=oyNZ2-GkzpZ38XfikRpyYxm3t3mzYqJGbALzIrNX5y4,52
|
|
3
|
+
blot/classify.py,sha256=bJ7P8nG9y1Oj2FfQK2L1Gw5nUf5ncQL61VzPh-Gilo8,3883
|
|
4
|
+
blot/cli.py,sha256=SUhSVNXoF3Ir-aUd5UHUIwgf8zHNig1XuhMQ7NXtVD8,4611
|
|
5
|
+
blot/config.py,sha256=ODXJ80Z29awuu88m78xZoWDBehZTvrQf8hlIXhlT_tk,2494
|
|
6
|
+
blot/diff.py,sha256=dEnm7h3ZEvlma76MpoTC9VtlPMPq6UNaw0TbXee73sc,2789
|
|
7
|
+
blot/git.py,sha256=lmmhM2mJQ2ouN3MxvhnTBqWtD39x28JVadsM3q3Dnkk,410
|
|
8
|
+
blot/hooks.py,sha256=7keSL3VxotCl3740hvB_3QHAQeuKDYk0oYJx2oDCdyI,2669
|
|
9
|
+
blot/index.py,sha256=i7u7SGpyx19nBVR5GErzq1ZxEzgLjiTwPV1FWr4JqN0,5436
|
|
10
|
+
blot/report.py,sha256=jKTxWdmFOr9yHoySOhRiw6v38_vCxYYwAApG7brAYPc,799
|
|
11
|
+
blot/store.py,sha256=F99MFNviCLAjfdzNmO4UPbcoWrm6bqLmosxRlqnlTdA,6683
|
|
12
|
+
blot/checks/__init__.py,sha256=GPt2jo5SXwv62FRFpvoRQjKWxWiIn9bMx9W4ZHnhNds,541
|
|
13
|
+
blot/checks/base.py,sha256=J7Xm9GQ1GAQSGklknB3tr4BNH6Q7bXkh2UubGa9oNls,977
|
|
14
|
+
blot/checks/cochange.py,sha256=Ru38RkHeV6q-YU8sPioEan9LPC84j7_xnVMIg12eXJQ,990
|
|
15
|
+
blot/checks/readded_bug.py,sha256=v2dnRkhzzdenq8Q2Mgoq1dCd-tOUx8x9AQbqnoCfIRE,853
|
|
16
|
+
blot/checks/removed_fix.py,sha256=hjduW6vX8vbaguvbBt8ZEjiy8NzhFq3u7AW3s9Eck4M,791
|
|
17
|
+
blot_git-0.0.1.dist-info/METADATA,sha256=uAz9LFisdxy9CpX8SAGRQrqN5q53JmDc_Shvfl_jOsQ,1834
|
|
18
|
+
blot_git-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
19
|
+
blot_git-0.0.1.dist-info/entry_points.txt,sha256=7V9oo1pus8MDkPa_48M7jRFokfz291bySD2a_iART40,39
|
|
20
|
+
blot_git-0.0.1.dist-info/licenses/LICENSE,sha256=IDNp7cs4d4lB1xwgGPf4W5Zp3yUNa3REMjw2T4bM-Gs,1062
|
|
21
|
+
blot_git-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shyam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|