gitfence 0.1.0__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.
Files changed (59) hide show
  1. fence/__init__.py +3 -0
  2. fence/anchors/__init__.py +0 -0
  3. fence/anchors/fingerprint.py +69 -0
  4. fence/check/__init__.py +0 -0
  5. fence/check/classify.py +84 -0
  6. fence/check/diff.py +127 -0
  7. fence/check/engine.py +263 -0
  8. fence/cli.py +576 -0
  9. fence/config.py +209 -0
  10. fence/context.py +77 -0
  11. fence/decisions/__init__.py +0 -0
  12. fence/decisions/build.py +579 -0
  13. fence/decisions/model.py +117 -0
  14. fence/decisions/obsolete.py +121 -0
  15. fence/decisions/reasons.py +150 -0
  16. fence/decisions/relevance.py +146 -0
  17. fence/diffing/__init__.py +0 -0
  18. fence/diffing/ranges.py +25 -0
  19. fence/diffing/symbols.py +21 -0
  20. fence/eval/__init__.py +0 -0
  21. fence/eval/backtest.py +271 -0
  22. fence/git/__init__.py +0 -0
  23. fence/git/blame.py +219 -0
  24. fence/git/history.py +171 -0
  25. fence/git/log.py +60 -0
  26. fence/git/runner.py +248 -0
  27. fence/github/__init__.py +0 -0
  28. fence/github/client.py +122 -0
  29. fence/github/comment.py +50 -0
  30. fence/github/sync.py +123 -0
  31. fence/globs.py +43 -0
  32. fence/index/__init__.py +0 -0
  33. fence/index/formatting.py +53 -0
  34. fence/index/ghosts.py +143 -0
  35. fence/index/indexer.py +219 -0
  36. fence/index/store.py +389 -0
  37. fence/lang/__init__.py +118 -0
  38. fence/lang/python.py +67 -0
  39. fence/lang/treesitter.py +165 -0
  40. fence/mcp/__init__.py +0 -0
  41. fence/mcp/server.py +306 -0
  42. fence/policy/__init__.py +0 -0
  43. fence/policy/acks.py +88 -0
  44. fence/render.py +287 -0
  45. fence/report.py +103 -0
  46. fence/scan.py +50 -0
  47. fence/scoring/__init__.py +0 -0
  48. fence/scoring/calibration.py +133 -0
  49. fence/scoring/strength.py +198 -0
  50. fence/signals/__init__.py +0 -0
  51. fence/signals/catalog.py +161 -0
  52. fence/signals/code.py +69 -0
  53. fence/text.py +43 -0
  54. fence/trace.py +65 -0
  55. gitfence-0.1.0.dist-info/METADATA +389 -0
  56. gitfence-0.1.0.dist-info/RECORD +59 -0
  57. gitfence-0.1.0.dist-info/WHEEL +4 -0
  58. gitfence-0.1.0.dist-info/entry_points.txt +3 -0
  59. gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fence: records why code exists and warns before it is removed."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,69 @@
1
+ """Content fingerprints: find the same code again after it moves or is reformatted.
2
+
3
+ `exact` is a hash of the normalized token stream: equal means "same code,
4
+ modulo layout, comments and quote style". `shingles` are hashed k-token windows:
5
+ their Jaccard similarity says how much code two snippets share, which survives
6
+ renames of the surrounding file, moves between files and small edits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ SHINGLE_K = 5
16
+ SAME_CODE_JACCARD = 0.7
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Fingerprint:
21
+ exact: str
22
+ shingles: frozenset[int]
23
+ token_count: int
24
+
25
+ def to_json(self) -> dict:
26
+ return {"exact": self.exact, "shingles": sorted(self.shingles), "n": self.token_count}
27
+
28
+ @classmethod
29
+ def from_json(cls, d: dict) -> Fingerprint:
30
+ return cls(d["exact"], frozenset(d["shingles"]), d["n"])
31
+
32
+
33
+ def fingerprint(tokens: Sequence[str], k: int = SHINGLE_K) -> Fingerprint:
34
+ joined = "\x00".join(tokens).encode("utf-8", errors="replace")
35
+ exact = hashlib.sha256(joined).hexdigest()[:24]
36
+ return Fingerprint(exact, shingles(tokens, k), len(tokens))
37
+
38
+
39
+ def shingles(tokens: Sequence[str], k: int = SHINGLE_K) -> frozenset[int]:
40
+ if not tokens:
41
+ return frozenset()
42
+ if len(tokens) < k:
43
+ windows = [tuple(tokens)]
44
+ else:
45
+ windows = [tuple(tokens[i : i + k]) for i in range(len(tokens) - k + 1)]
46
+ out = set()
47
+ for w in windows:
48
+ h = hashlib.blake2b("\x00".join(w).encode("utf-8", "replace"), digest_size=8)
49
+ out.add(int.from_bytes(h.digest(), "big") >> 1) # fits in a signed 64-bit int
50
+ return frozenset(out)
51
+
52
+
53
+ def jaccard(a: frozenset[int], b: frozenset[int]) -> float:
54
+ if not a and not b:
55
+ return 1.0
56
+ if not a or not b:
57
+ return 0.0
58
+ return len(a & b) / len(a | b)
59
+
60
+
61
+ def containment(needle: frozenset[int], haystack: frozenset[int]) -> float:
62
+ """Share of `needle`'s shingles found in `haystack` (moved code inside a bigger file)."""
63
+ if not needle:
64
+ return 0.0
65
+ return len(needle & haystack) / len(needle)
66
+
67
+
68
+ def same_code(a: Fingerprint, b: Fingerprint, threshold: float = SAME_CODE_JACCARD) -> bool:
69
+ return a.exact == b.exact or jaccard(a.shingles, b.shingles) >= threshold
File without changes
@@ -0,0 +1,84 @@
1
+ """What kind of change is a block? delete > loosen > alter > move (by risk).
2
+
3
+ - move: the removed code reappears elsewhere in the diff with its guards intact.
4
+ - loosen: a guard, lock, retry, timeout, assertion or error handler is removed or
5
+ weakened (its code signal disappears, or a number on such a line changes),
6
+ including a move that silently drops one.
7
+ - delete: nothing meaningful replaces the removed lines.
8
+ - alter: anything else (expressions or constants changed).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ from fence.anchors.fingerprint import containment, jaccard, shingles
17
+ from fence.check.diff import Block
18
+ from fence.lang import tokens_for
19
+ from fence.signals.code import match_code
20
+
21
+ GUARD_SIGNALS = {
22
+ "code.guard", "code.lock", "code.retry_timeout", "code.exception_handling",
23
+ "code.assertion", "code.feature_flag",
24
+ }
25
+ _NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?![\w.])")
26
+ MOVE_CONTAINMENT = 0.8
27
+ DELETE_SIMILARITY = 0.3
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Classification:
32
+ kind: str # delete | loosen | alter | move
33
+ reason: str
34
+ lost_signals: tuple[str, ...] = ()
35
+
36
+
37
+ def classify(path: str, block: Block, diff_added: frozenset[int]) -> Classification:
38
+ """`diff_added` is the shingle set of every added line in the whole diff."""
39
+ removed = [t for _, t in block.removed]
40
+ added = [t for _, t in block.added]
41
+ r_tokens = tokens_for(path, "\n".join(removed))
42
+ a_tokens = tokens_for(path, "\n".join(added))
43
+ if not r_tokens:
44
+ return Classification("alter", "only blank lines or comments removed")
45
+ r_sigs = {s for s, _ in match_code(removed)} & GUARD_SIGNALS
46
+ a_sigs = {s for s, _ in match_code(added)} & GUARD_SIGNALS
47
+
48
+ r_sh = shingles(r_tokens)
49
+ if len(r_tokens) >= 4 and containment(r_sh, diff_added) >= MOVE_CONTAINMENT and \
50
+ jaccard(r_sh, shingles(a_tokens)) < MOVE_CONTAINMENT:
51
+ moved_sigs = r_sigs - a_sigs
52
+ dest_ok = all(_signal_survives(s, diff_added, path, removed) for s in moved_sigs)
53
+ if dest_ok:
54
+ return Classification("move", "the removed code reappears elsewhere in this change")
55
+
56
+ lost = tuple(sorted(r_sigs - a_sigs))
57
+ if not a_tokens:
58
+ return Classification("delete", "lines removed with no replacement", lost)
59
+ if jaccard(r_sh, shingles(a_tokens)) < DELETE_SIMILARITY and len(r_tokens) >= 8:
60
+ return Classification("delete", "replaced by unrelated code", lost)
61
+ if lost:
62
+ names = ", ".join(s.split(".", 1)[1].replace("_", " ") for s in lost)
63
+ return Classification("loosen", f"removes or weakens: {names}", lost)
64
+ if _guard_number_changed(removed, added):
65
+ return Classification("loosen", "a retry, timeout, lock or guard value changed")
66
+ return Classification("alter", "code changed in place")
67
+
68
+
69
+ def _signal_survives(sid: str, diff_added: frozenset[int], path: str, removed: list[str]) -> bool:
70
+ # The guard line itself must be among the moved shingles.
71
+ guard_lines = [t for t in removed if sid in {s for s, _ in match_code([t])}]
72
+ if not guard_lines:
73
+ return True
74
+ sh = shingles(tokens_for(path, "\n".join(guard_lines)))
75
+ return containment(sh, diff_added) >= MOVE_CONTAINMENT
76
+
77
+
78
+ def _guard_number_changed(removed: list[str], added: list[str]) -> bool:
79
+ guarded = [t for t in removed if {s for s, _ in match_code([t])} & GUARD_SIGNALS]
80
+ if not guarded:
81
+ return False
82
+ before = [_NUMBER.findall(t) for t in guarded]
83
+ after_all = [_NUMBER.findall(t) for t in added]
84
+ return any(nums and nums not in after_all for nums in before)
fence/check/diff.py ADDED
@@ -0,0 +1,127 @@
1
+ """Unified diff -> change blocks: runs of removed lines and the lines that replaced them.
2
+
3
+ A block is the unit Fence judges: "these base lines were removed, and these lines
4
+ took their place" (possibly none). Context lines end a block.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from unidiff import PatchSet
12
+ from unidiff.errors import UnidiffParseError
13
+
14
+ from fence.git.runner import GitError, Repo
15
+
16
+ MAX_DIFF_BYTES = 20_000_000
17
+ MAX_FILES = 2_000
18
+
19
+
20
+ class DiffError(Exception):
21
+ """The diff could not be produced or parsed."""
22
+
23
+
24
+ @dataclass
25
+ class Block:
26
+ removed: list[tuple[int, str]] # (base line number, text)
27
+ added: list[tuple[int, str]] # (new line number, text)
28
+
29
+ @property
30
+ def start(self) -> int:
31
+ return self.removed[0][0]
32
+
33
+ @property
34
+ def end(self) -> int:
35
+ return self.removed[-1][0]
36
+
37
+
38
+ @dataclass
39
+ class FileDiff:
40
+ old_path: str | None # None for added files
41
+ new_path: str | None # None for deleted files
42
+ status: str # added | deleted | modified | renamed
43
+ blocks: list[Block] = field(default_factory=list)
44
+ added_lines: list[str] = field(default_factory=list) # every added line, for move detection
45
+ binary: bool = False
46
+
47
+ @property
48
+ def path(self) -> str:
49
+ return self.new_path or self.old_path or ""
50
+
51
+
52
+ def _strip(p: str | None, prefix: str) -> str | None:
53
+ if p is None or p == "/dev/null":
54
+ return None
55
+ return p[len(prefix):] if p.startswith(prefix) else p
56
+
57
+
58
+ def parse_diff(text: str) -> list[FileDiff]:
59
+ if len(text.encode("utf-8", "replace")) > MAX_DIFF_BYTES:
60
+ raise DiffError(f"diff is larger than {MAX_DIFF_BYTES // 1_000_000} MB")
61
+ try:
62
+ patch = PatchSet(text)
63
+ except UnidiffParseError as e:
64
+ raise DiffError(f"could not parse diff: {e}") from None
65
+ if len(patch) > MAX_FILES:
66
+ raise DiffError(f"diff touches more than {MAX_FILES} files")
67
+ out: list[FileDiff] = []
68
+ for pf in patch:
69
+ old = _strip(pf.source_file, "a/")
70
+ new = _strip(pf.target_file, "b/")
71
+ status = ("added" if pf.is_added_file else "deleted" if pf.is_removed_file
72
+ else "renamed" if pf.is_rename else "modified")
73
+ fd = FileDiff(old, new, status, binary=pf.is_binary_file)
74
+ for hunk in pf:
75
+ removed: list[tuple[int, str]] = []
76
+ added: list[tuple[int, str]] = []
77
+ for line in hunk:
78
+ value = line.value[:-1] if line.value.endswith("\n") else line.value
79
+ if line.is_removed:
80
+ if added: # a '-' after '+' starts a new block
81
+ _flush(fd, removed, added)
82
+ removed, added = [], []
83
+ removed.append((line.source_line_no, value))
84
+ elif line.is_added:
85
+ added.append((line.target_line_no, value))
86
+ fd.added_lines.append(value)
87
+ else:
88
+ _flush(fd, removed, added)
89
+ removed, added = [], []
90
+ _flush(fd, removed, added)
91
+ out.append(fd)
92
+ return out
93
+
94
+
95
+ def _flush(fd: FileDiff, removed: list, added: list) -> None:
96
+ if removed:
97
+ fd.blocks.append(Block(list(removed), list(added)))
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class DiffSpec:
102
+ """What to check. `base` is the revision removed lines are traced at."""
103
+
104
+ base: str
105
+ head: str | None = None # None: working tree (or index with staged=True)
106
+ staged: bool = False
107
+
108
+
109
+ def produce_diff(repo: Repo, spec: DiffSpec, *, rename_threshold: int = 50) -> tuple[str, str]:
110
+ """Run git diff for `spec`. Returns (base_sha, diff_text)."""
111
+ base = repo.verify_ref(spec.base)
112
+ args = ["diff", "--no-color", "--no-ext-diff", "--no-textconv", f"-M{rename_threshold}%",
113
+ "--src-prefix=a/", "--dst-prefix=b/", "--unified=3"]
114
+ if spec.staged:
115
+ args.append("--cached")
116
+ args.append(base)
117
+ if spec.head:
118
+ args.append(repo.verify_ref(spec.head))
119
+ args.append("--")
120
+ try:
121
+ return base, "".join(repo.stream(args, timeout=max(repo.timeout, 60)))
122
+ except GitError as e:
123
+ raise DiffError(str(e)) from None
124
+
125
+
126
+ def merge_base(repo: Repo, a: str, b: str) -> str:
127
+ return repo.run(["merge-base", repo.verify_ref(a), repo.verify_ref(b)]).strip()
fence/check/engine.py ADDED
@@ -0,0 +1,263 @@
1
+ """The fence check: a diff in, a justified verdict per change block out.
2
+
3
+ Verdicts:
4
+ KEEP strong, active decision contradicted: stop and verify (can fail CI)
5
+ VERIFY a decision is touched and its reason is only partly known: review it
6
+ CLEAR nothing meaningful affected
7
+ SAFE-TO-REMOVE-CANDIDATE the reason is known and evidence says it is obsolete
8
+
9
+ Only KEEP can fail a run (exit 2 or 3), and only for grades at or above
10
+ scoring.grade_floor_for_block: "reason unknown" never blocks anyone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ from fence.anchors.fingerprint import fingerprint, shingles
22
+ from fence.check.classify import Classification, classify
23
+ from fence.check.diff import DiffError, DiffSpec, FileDiff, parse_diff, produce_diff
24
+ from fence.config import BANDS, Config, PathOverride
25
+ from fence.context import Context
26
+ from fence.decisions.build import TEST_PATH, Target, decisions_for_lines, format_only_shas
27
+ from fence.decisions.model import DecisionRecord, Score
28
+ from fence.diffing.ranges import RemovedRange
29
+ from fence.git.blame import MAX_LINES
30
+ from fence.git.runner import GitError
31
+ from fence.globs import match_any
32
+ from fence.index.ghosts import find_ghosts
33
+ from fence.lang import tokens_for
34
+ from fence.policy import acks as ackmod
35
+ from fence.scoring.strength import change_risk
36
+ from fence.text import clean
37
+ from fence.trace import PreimageMismatch, trace
38
+
39
+ KEEP, VERIFY, CLEAR, SAFE = "KEEP", "VERIFY", "CLEAR", "SAFE-TO-REMOVE-CANDIDATE"
40
+ VERDICT_ORDER = (CLEAR, SAFE, VERIFY, KEEP)
41
+ GRADE_ORDER = "ABCD"
42
+
43
+ EXIT_OK, EXIT_ERROR, EXIT_POLICY, EXIT_ACK, EXIT_NO_INDEX = 0, 1, 2, 3, 4
44
+
45
+
46
+ @dataclass
47
+ class Finding:
48
+ path: str
49
+ start: int # base line numbers of the removed block
50
+ end: int
51
+ kind: str
52
+ kind_reason: str
53
+ verdict: str
54
+ risk: Score
55
+ decision: DecisionRecord
56
+ decision_fingerprint: str
57
+ removed_preview: str
58
+ lost_signals: tuple[str, ...] = ()
59
+ ack: ackmod.Ack | None = None
60
+
61
+ @property
62
+ def blocking(self) -> bool:
63
+ return self.verdict == KEEP and self.ack is None
64
+
65
+
66
+ @dataclass
67
+ class CheckResult:
68
+ base: str
69
+ head: str | None
70
+ findings: list[Finding] = field(default_factory=list)
71
+ verdict: str = CLEAR
72
+ exit_code: int = EXIT_OK
73
+ files_checked: int = 0
74
+ blocks_checked: int = 0
75
+ notes: list[str] = field(default_factory=list)
76
+ bypassed: bool = False
77
+ ghosts: list = field(default_factory=list) # (path, Ghost)
78
+ seconds: float = 0.0
79
+
80
+
81
+ def effective_policy(cfg: Config, path: str) -> tuple[str, str, int, str]:
82
+ """(fail_on_band, require_ack_for, risk_bonus, reason) after path overrides.
83
+ When several overrides match, the strictest value of each wins."""
84
+ fail_on, ack_for, bonus, why = cfg.policy.fail_on_band, cfg.policy.require_ack_for, 0, ""
85
+ for pattern, ov in cfg.policy.path_overrides.items():
86
+ if not match_any(path, [pattern]):
87
+ continue
88
+ ov: PathOverride
89
+ if ov.fail_on_band and BANDS.index(ov.fail_on_band) < BANDS.index(fail_on):
90
+ fail_on = ov.fail_on_band
91
+ if ov.require_ack_for and BANDS.index(ov.require_ack_for) < BANDS.index(ack_for):
92
+ ack_for = ov.require_ack_for
93
+ if ov.risk_bonus > bonus:
94
+ bonus, why = ov.risk_bonus, f"sensitive path ({pattern})"
95
+ return fail_on, ack_for, bonus, why
96
+
97
+
98
+ def check(
99
+ ctx: Context, spec: DiffSpec | None = None, *, diff_text: str | None = None,
100
+ base: str | None = None, deep: bool = True, include_clear: bool = False,
101
+ ) -> CheckResult:
102
+ started = time.monotonic()
103
+ if diff_text is None:
104
+ spec = spec or DiffSpec(base="HEAD")
105
+ base_sha, diff_text = produce_diff(ctx.repo, spec,
106
+ rename_threshold=ctx.cfg.index.follow_renames_sensitivity)
107
+ head = spec.head
108
+ else:
109
+ base_sha = ctx.repo.verify_ref(base or "HEAD")
110
+ head = None
111
+ result = CheckResult(base=base_sha, head=head)
112
+ files = parse_diff(diff_text)
113
+ ledger = ctx.repo.root / ctx.cfg.policy.ack_ledger
114
+ acks, problems = ackmod.load(ledger)
115
+ result.notes += problems
116
+ if ctx.store.commit_count() == 0:
117
+ result.notes.append("index is empty: run `fence index` for full evidence")
118
+
119
+ diff_added = frozenset().union(*(
120
+ shingles(tokens_for(f.path, "\n".join(f.added_lines))) for f in files if f.added_lines
121
+ )) if files else frozenset()
122
+ tests_removed = any(
123
+ (f.status == "deleted" or f.blocks) and TEST_PATH.search(f.old_path or "")
124
+ for f in files
125
+ )
126
+ now = datetime.now(UTC)
127
+ for fd in files:
128
+ if not fd.blocks or fd.old_path is None or fd.binary:
129
+ continue
130
+ if match_any(fd.old_path, ctx.cfg.policy.ignore_paths):
131
+ continue
132
+ result.files_checked += 1
133
+ result.blocks_checked += len(fd.blocks)
134
+ try:
135
+ result.findings += _check_file(ctx, fd, base_sha, diff_added, tests_removed,
136
+ acks, now, result.notes, deep=deep,
137
+ include_clear=include_clear)
138
+ except PreimageMismatch as e:
139
+ raise DiffError(str(e)) from None
140
+ for fd in files:
141
+ if fd.added_lines and not match_any(fd.path, ctx.cfg.policy.ignore_paths):
142
+ result.ghosts += [(fd.path, g)
143
+ for g in find_ghosts(ctx.store, fd.path, fd.added_lines)]
144
+
145
+ # Within equal risk, blocks that drop a guard/retry/lock come first: they are the
146
+ # lines a reviewer most needs to see.
147
+ result.findings.sort(key=lambda f: (-VERDICT_ORDER.index(f.verdict), -f.risk.value,
148
+ -len(f.lost_signals), f.path, f.start))
149
+ if result.findings:
150
+ result.verdict = result.findings[0].verdict
151
+ bypass_var = ctx.cfg.policy.allow_bypass_env
152
+ result.bypassed = bool(bypass_var and os.environ.get(bypass_var))
153
+ result.exit_code = _exit_code(ctx.cfg, result)
154
+ result.seconds = round(time.monotonic() - started, 3)
155
+ return result
156
+
157
+
158
+ def _exit_code(cfg: Config, result: CheckResult) -> int:
159
+ if result.bypassed:
160
+ return EXIT_OK
161
+ code = EXIT_OK
162
+ for f in result.findings:
163
+ if not f.blocking:
164
+ continue
165
+ _, ack_for, _, _ = effective_policy(cfg, f.path)
166
+ if BANDS.index(f.risk.band) >= BANDS.index(ack_for):
167
+ return EXIT_ACK
168
+ code = EXIT_POLICY
169
+ return code
170
+
171
+
172
+ def _check_file(
173
+ ctx: Context, fd: FileDiff, base_sha: str, diff_added: frozenset[int], tests_removed: bool,
174
+ acks: list[ackmod.Ack], now: datetime, notes: list[str], *, deep: bool,
175
+ include_clear: bool = False,
176
+ ) -> list[Finding]:
177
+ path = fd.old_path or ""
178
+ try:
179
+ text = ctx.repo.read_blob(base_sha, path, max_bytes=ctx.cfg.index.max_file_size)
180
+ except GitError as e:
181
+ notes.append(f"{path}: skipped ({e})")
182
+ return []
183
+ base_lines = text.split("\n")
184
+ blocks = fd.blocks
185
+ total = sum(b.end - b.start + 1 for b in blocks)
186
+ if total > MAX_LINES:
187
+ kept, n = [], 0
188
+ for b in blocks:
189
+ if n + (b.end - b.start + 1) > MAX_LINES:
190
+ break
191
+ kept.append(b)
192
+ n += b.end - b.start + 1
193
+ notes.append(f"{path}: {total} removed lines; only the first {n} were traced")
194
+ blocks = kept
195
+ ranges = [RemovedRange(path, b.start, b.end, tuple(t for _, t in b.removed)) for b in blocks]
196
+ history = ctx.store.path_history(path)
197
+ traced = trace(ctx.repo, base_sha, ranges, extra_ignore=format_only_shas(ctx, history))
198
+ blamed = [bl for t in traced for bl in t.lines]
199
+ target = Target(path, blocks[0].start, blocks[-1].end, None, base_sha)
200
+ decisions = decisions_for_lines(ctx, target, base_lines, blamed, history, deep=deep)
201
+ owner: dict[int, DecisionRecord] = {}
202
+ for d in decisions:
203
+ for a in d.anchors:
204
+ for n in range(a.start_line, a.end_line + 1):
205
+ owner[n] = d
206
+
207
+ fail_on, _, bonus, bonus_reason = effective_policy(ctx.cfg, path)
208
+ findings: list[Finding] = []
209
+ for b in blocks:
210
+ cls = classify(path, b, diff_added)
211
+ touched = {owner[n].id: owner[n] for n, _ in b.removed if n in owner}
212
+ best: Finding | None = None
213
+ for d in touched.values():
214
+ risk = change_risk(d.strength, cls.kind, ctx.cfg, tests_removed=tests_removed,
215
+ path_bonus=bonus, path_bonus_reason=bonus_reason,
216
+ low_signal=match_any(path, ctx.cfg.scoring.low_signal_paths))
217
+ f = Finding(
218
+ path=path, start=b.start, end=b.end, kind=cls.kind, kind_reason=cls.reason,
219
+ verdict=_verdict(ctx.cfg, d, risk, fail_on, cls), risk=risk, decision=d,
220
+ decision_fingerprint=fingerprint(tokens_for(path, "\n".join(
221
+ t for n, t in b.removed if owner.get(n) is d))).exact,
222
+ removed_preview=clean("\n".join(t for _, t in b.removed[:6]), max_len=400),
223
+ lost_signals=cls.lost_signals,
224
+ )
225
+ if best is None or (VERDICT_ORDER.index(f.verdict), f.risk.value) > (
226
+ VERDICT_ORDER.index(best.verdict), best.risk.value):
227
+ best = f
228
+ if best is None or (best.verdict == CLEAR and not include_clear):
229
+ continue
230
+ best.ack = ackmod.find_valid(acks, best.decision.id, best.decision_fingerprint, now)
231
+ findings.append(best)
232
+ return findings
233
+
234
+
235
+ def _verdict(cfg: Config, d: DecisionRecord, risk: Score, fail_on: str,
236
+ cls: Classification) -> str:
237
+ if d.status == "stale-suspected" and d.grade in ("A", "B"):
238
+ return SAFE
239
+ can_block = (
240
+ GRADE_ORDER.index(d.grade) <= GRADE_ORDER.index(cfg.scoring.grade_floor_for_block)
241
+ and d.status == "active"
242
+ and risk.confidence >= cfg.scoring.min_confidence_to_warn
243
+ )
244
+ if can_block and BANDS.index(risk.band) >= BANDS.index(fail_on):
245
+ return KEEP
246
+ # VERIFY needs scoring.verify_min_risk, except a stated reason (A/B) of a high-stakes
247
+ # type, which warns from the medium band. Backtest: other A/B warnings below 50 were
248
+ # 60 false alarms for 2 regrets (DECISIONS.md).
249
+ stated_high = d.grade in ("A", "B") and d.type in cfg.scoring.high_stakes_types
250
+ floor = cfg.scoring.bands.medium if stated_high else cfg.scoring.verify_min_risk
251
+ if risk.value >= floor and cls.kind != "move":
252
+ return VERIFY
253
+ return CLEAR
254
+
255
+
256
+ def _decision_fingerprint(path: str, base_lines: list[str], d: DecisionRecord) -> str:
257
+ owned = [base_lines[n - 1] for a in d.anchors for n in range(a.start_line, a.end_line + 1)
258
+ if n - 1 < len(base_lines)]
259
+ return fingerprint(tokens_for(path, "\n".join(owned))).exact
260
+
261
+
262
+ def ledger_path(ctx: Context) -> Path:
263
+ return ctx.repo.root / ctx.cfg.policy.ack_ledger