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.
- fence/__init__.py +3 -0
- fence/anchors/__init__.py +0 -0
- fence/anchors/fingerprint.py +69 -0
- fence/check/__init__.py +0 -0
- fence/check/classify.py +84 -0
- fence/check/diff.py +127 -0
- fence/check/engine.py +263 -0
- fence/cli.py +576 -0
- fence/config.py +209 -0
- fence/context.py +77 -0
- fence/decisions/__init__.py +0 -0
- fence/decisions/build.py +579 -0
- fence/decisions/model.py +117 -0
- fence/decisions/obsolete.py +121 -0
- fence/decisions/reasons.py +150 -0
- fence/decisions/relevance.py +146 -0
- fence/diffing/__init__.py +0 -0
- fence/diffing/ranges.py +25 -0
- fence/diffing/symbols.py +21 -0
- fence/eval/__init__.py +0 -0
- fence/eval/backtest.py +271 -0
- fence/git/__init__.py +0 -0
- fence/git/blame.py +219 -0
- fence/git/history.py +171 -0
- fence/git/log.py +60 -0
- fence/git/runner.py +248 -0
- fence/github/__init__.py +0 -0
- fence/github/client.py +122 -0
- fence/github/comment.py +50 -0
- fence/github/sync.py +123 -0
- fence/globs.py +43 -0
- fence/index/__init__.py +0 -0
- fence/index/formatting.py +53 -0
- fence/index/ghosts.py +143 -0
- fence/index/indexer.py +219 -0
- fence/index/store.py +389 -0
- fence/lang/__init__.py +118 -0
- fence/lang/python.py +67 -0
- fence/lang/treesitter.py +165 -0
- fence/mcp/__init__.py +0 -0
- fence/mcp/server.py +306 -0
- fence/policy/__init__.py +0 -0
- fence/policy/acks.py +88 -0
- fence/render.py +287 -0
- fence/report.py +103 -0
- fence/scan.py +50 -0
- fence/scoring/__init__.py +0 -0
- fence/scoring/calibration.py +133 -0
- fence/scoring/strength.py +198 -0
- fence/signals/__init__.py +0 -0
- fence/signals/catalog.py +161 -0
- fence/signals/code.py +69 -0
- fence/text.py +43 -0
- fence/trace.py +65 -0
- gitfence-0.1.0.dist-info/METADATA +389 -0
- gitfence-0.1.0.dist-info/RECORD +59 -0
- gitfence-0.1.0.dist-info/WHEEL +4 -0
- gitfence-0.1.0.dist-info/entry_points.txt +3 -0
- gitfence-0.1.0.dist-info/licenses/LICENSE +21 -0
fence/__init__.py
ADDED
|
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
|
fence/check/__init__.py
ADDED
|
File without changes
|
fence/check/classify.py
ADDED
|
@@ -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
|