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 ADDED
@@ -0,0 +1,3 @@
1
+ """blot: history-aware pre-commit checks."""
2
+
3
+ __version__ = "0.0.1"
blot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from blot.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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."""
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blot = blot.cli:main
@@ -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.