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/render.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Human and JSON output. Verdict first, one-sentence reason, evidence, then detail.
|
|
2
|
+
|
|
3
|
+
Meaning never depends on colour: every coloured word is also a text label, so
|
|
4
|
+
output reads the same in plain terminals, CI logs and screen readers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from fence.check.engine import CLEAR, KEEP, SAFE, VERIFY, CheckResult, Finding
|
|
18
|
+
from fence.decisions.build import WhyResult
|
|
19
|
+
from fence.decisions.model import SCHEMA, SCORE_NOTE, DecisionRecord, Evidence, Score
|
|
20
|
+
from fence.text import quoted
|
|
21
|
+
|
|
22
|
+
_COLORS = {KEEP: "red", VERIFY: "yellow", CLEAR: "green", SAFE: "cyan",
|
|
23
|
+
"critical": "red", "high": "red", "medium": "yellow", "low": "green"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Out:
|
|
27
|
+
def __init__(self, color: str = "auto", *, max_items: int = 10, verbose: bool = False) -> None:
|
|
28
|
+
if color == "auto":
|
|
29
|
+
self.color = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
30
|
+
else:
|
|
31
|
+
self.color = color == "always"
|
|
32
|
+
self.max_items = max_items
|
|
33
|
+
self.verbose = verbose
|
|
34
|
+
|
|
35
|
+
def c(self, text: str, key: str | None = None, *, bold: bool = False) -> str:
|
|
36
|
+
if not self.color:
|
|
37
|
+
return text
|
|
38
|
+
return typer.style(text, fg=_COLORS.get(key or text), bold=bold)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _date(ts: int | None) -> str:
|
|
42
|
+
return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%d") if ts else "unknown date"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ev_line(e: Evidence) -> str:
|
|
46
|
+
status = "" if e.retrieval_status == "ok" else f" ({e.retrieval_status.replace('_', ' ')})"
|
|
47
|
+
ref = e.ref[:12] if e.type in ("commit", "revert") else e.ref
|
|
48
|
+
link = f" {e.url}" if e.url else ""
|
|
49
|
+
return f"{e.id} {e.type} {ref}{status}{link}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _factors(s: Score, n: int = 3) -> str:
|
|
53
|
+
return "; ".join(f"{f.points:+g} {f.reason}" for f in s.top(n))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _reason(d: DecisionRecord) -> str:
|
|
57
|
+
if d.stated_reason:
|
|
58
|
+
src = next((e for e in d.evidence if e.id == d.stated_reason_evidence), None)
|
|
59
|
+
where = f"{src.type} {src.ref[:12] if src.type == 'commit' else src.ref}" if src else "?"
|
|
60
|
+
return f"{quoted(d.stated_reason)} (quoted from {where})"
|
|
61
|
+
return d.reason_text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decision_text(d: DecisionRecord, o: Out, *, full: bool = False) -> list[str]:
|
|
65
|
+
s = d.strength
|
|
66
|
+
commit = d.evidence[0]
|
|
67
|
+
head = (f"{o.c(f'[{s.band.upper()} {s.value}]', s.band, bold=True)} {d.type} | grade {d.grade}"
|
|
68
|
+
f" | confidence {s.confidence:.2f} | {d.id}")
|
|
69
|
+
lines = [head, f" Why: {_reason(d)}"]
|
|
70
|
+
subject = (commit.quote or "").split("\n", 1)[0]
|
|
71
|
+
lines.append(f" Introduced {_date(d.introduced_at)} by {commit.author or 'unknown'} in "
|
|
72
|
+
f"{d.introduced_sha[:12]} \"{subject}\"")
|
|
73
|
+
spans = ", ".join(f"{a.start_line}" + (f"-{a.end_line}" if a.end_line != a.start_line else "")
|
|
74
|
+
+ (f" ({a.symbol})" if a.symbol else "") for a in d.anchors)
|
|
75
|
+
lines.append(f" Lines: {spans}" + (f" | status {d.status}" if d.status != "active" else ""))
|
|
76
|
+
if d.constraints:
|
|
77
|
+
lines.append(" Constraint: " + " | ".join(f'"{c}"' for c in d.constraints[:2]))
|
|
78
|
+
lines.append(f" Top factors: {_factors(s, 99 if full else 3)}")
|
|
79
|
+
ev = d.evidence if full else d.evidence[:4]
|
|
80
|
+
lines.append(" Evidence:" + ("" if ev else " none"))
|
|
81
|
+
lines += [f" {_ev_line(e)}" for e in ev]
|
|
82
|
+
if len(d.evidence) > len(ev):
|
|
83
|
+
lines.append(f" ... {len(d.evidence) - len(ev)} more (use --verbose)")
|
|
84
|
+
if s.what_would_change:
|
|
85
|
+
lines.append(" Would change this: " + "; ".join(s.what_would_change))
|
|
86
|
+
return lines
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def why_text(r: WhyResult, o: Out, *, full: bool = False) -> str:
|
|
90
|
+
t = r.target
|
|
91
|
+
span = f"{t.start}" if t.start == t.end else f"{t.start}-{t.end}"
|
|
92
|
+
sym = f" ({t.symbol})" if t.symbol else ""
|
|
93
|
+
n = len(r.decisions)
|
|
94
|
+
out = [f"{t.path}:{span}{sym} @ {t.rev[:12]} | {n} decision{'s' if n != 1 else ''}"]
|
|
95
|
+
if r.unexplained_lines:
|
|
96
|
+
out.append(f"{r.unexplained_lines} line(s) have no recoverable reason (grade D).")
|
|
97
|
+
for d in r.decisions[: o.max_items]:
|
|
98
|
+
out.append("")
|
|
99
|
+
out += decision_text(d, o, full=full or o.verbose)
|
|
100
|
+
if n > o.max_items:
|
|
101
|
+
out.append(f"\n... {n - o.max_items} more decisions (raise --max-items)")
|
|
102
|
+
if r.notes or r.decisions:
|
|
103
|
+
out.append("")
|
|
104
|
+
out += [f"Note: {x}" for x in r.notes]
|
|
105
|
+
if r.decisions:
|
|
106
|
+
out.append(f"Note: {SCORE_NOTE}")
|
|
107
|
+
return "\n".join(out)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ack_command(f: Finding, base: str, head_is_base: bool) -> str:
|
|
111
|
+
rev = "" if head_is_base else f" --rev {base[:12]}"
|
|
112
|
+
span = f"{f.start}" if f.start == f.end else f"{f.start}-{f.end}"
|
|
113
|
+
return (f'fence ack {f.decision.id} --at {f.path}:{span}{rev} '
|
|
114
|
+
f'--reason "<why it is safe to change>"')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def finding_text(f: Finding, o: Out, base: str, head_is_base: bool) -> list[str]:
|
|
118
|
+
d, r = f.decision, f.risk
|
|
119
|
+
span = f"{f.start}" if f.start == f.end else f"{f.start}-{f.end}"
|
|
120
|
+
label = o.c(f"{f.verdict:<6}", f.verdict, bold=True)
|
|
121
|
+
acked = " | ACKNOWLEDGED" if f.ack else ""
|
|
122
|
+
lines = [f"{label} {f.path}:{span} {f.kind} risk {r.value} ({r.band}) | "
|
|
123
|
+
f"evidence confidence {r.confidence:.2f} | grade {d.grade}{acked}"]
|
|
124
|
+
preview = f.removed_preview.split("\n")
|
|
125
|
+
lines.append(f" Change: {f.kind_reason}: " + preview[0].strip()
|
|
126
|
+
+ (f" (+{len(preview) - 1} lines)" if len(preview) > 1 else ""))
|
|
127
|
+
lines.append(f" Why it matters: {_reason(d)}")
|
|
128
|
+
lines.append(f" Decision {d.id}: {d.type}, introduced {_date(d.introduced_at)} in "
|
|
129
|
+
f"{d.introduced_sha[:12]}")
|
|
130
|
+
lines.append(" Evidence: " + ", ".join(
|
|
131
|
+
f"{e.type} {e.ref[:12] if e.type in ('commit', 'revert') else e.ref}"
|
|
132
|
+
+ ("" if e.retrieval_status == "ok" else f" ({e.retrieval_status.replace('_', ' ')})")
|
|
133
|
+
for e in d.evidence[:5]))
|
|
134
|
+
lines.append(f" Factors: {_factors(r)}")
|
|
135
|
+
if f.ack:
|
|
136
|
+
lines.append(f' Acknowledged by {f.ack.who} on {f.ack.when[:10]}: "{f.ack.reason}"')
|
|
137
|
+
elif f.verdict == KEEP:
|
|
138
|
+
lines.append(" Next: check with the decision's author or reviewers, or acknowledge:")
|
|
139
|
+
lines.append(f" {ack_command(f, base, head_is_base)}")
|
|
140
|
+
elif f.verdict == VERIFY:
|
|
141
|
+
lines.append(" Next: confirm the reason no longer applies before merging.")
|
|
142
|
+
elif f.verdict == SAFE:
|
|
143
|
+
lines.append(" Next: possibly obsolete; verify the evidence, then remove.")
|
|
144
|
+
return lines
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_text(r: CheckResult, o: Out) -> str:
|
|
148
|
+
head_is_base = r.head is None
|
|
149
|
+
counts = {v: sum(1 for f in r.findings if f.verdict == v) for v in (KEEP, VERIFY, SAFE)}
|
|
150
|
+
out = [f"{o.c(r.verdict, r.verdict, bold=True)}: "
|
|
151
|
+
+ (_summary_sentence(r, counts) if r.findings else "no meaningful decision affected.")]
|
|
152
|
+
shown = r.findings[: o.max_items]
|
|
153
|
+
for f in shown:
|
|
154
|
+
out.append("")
|
|
155
|
+
out += finding_text(f, o, r.base, head_is_base)
|
|
156
|
+
if len(r.findings) > len(shown):
|
|
157
|
+
out.append(f"\n... {len(r.findings) - len(shown)} more findings (raise --max-items)")
|
|
158
|
+
out.append("")
|
|
159
|
+
out.append(f"Checked {r.blocks_checked} change block(s) in {r.files_checked} file(s) "
|
|
160
|
+
f"against {r.base[:12]} in {r.seconds:.2f}s | exit {r.exit_code}"
|
|
161
|
+
+ (" (bypassed by env)" if r.bypassed else ""))
|
|
162
|
+
for path, g in r.ghosts:
|
|
163
|
+
out.append(f"Ghost fence: {path} re-adds code (similarity {g.similarity}) that was "
|
|
164
|
+
f"removed in {g.removed_in[:12]} \"{g.subject}\" from {g.path}.")
|
|
165
|
+
out += [f"Note: {x}" for x in r.notes]
|
|
166
|
+
if r.findings:
|
|
167
|
+
out.append(f"Note: {SCORE_NOTE}")
|
|
168
|
+
return "\n".join(out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _summary_sentence(r: CheckResult, counts: dict[str, int]) -> str:
|
|
172
|
+
parts = [f"{n} {v}" for v, n in counts.items() if n]
|
|
173
|
+
top = r.findings[0]
|
|
174
|
+
return (f"{', '.join(parts)}. Top: {top.path}:{top.start} touches a {top.decision.type} "
|
|
175
|
+
f"decision ({top.decision.grade}).")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
COMMENT_MARKER = "<!-- fence:sticky -->"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def check_markdown(r: CheckResult, ctx) -> str:
|
|
182
|
+
"""PR comment / job summary. Only meaningful findings; links where we have them."""
|
|
183
|
+
out = [COMMENT_MARKER, f"### Fence check: **{r.verdict}**", ""]
|
|
184
|
+
if not r.findings:
|
|
185
|
+
out.append("No meaningful decision affected by this change.")
|
|
186
|
+
for f in r.findings[:15]:
|
|
187
|
+
d = f.decision
|
|
188
|
+
commit = d.introduced_sha[:12]
|
|
189
|
+
url = ctx.commit_url(d.introduced_sha) if ctx else None
|
|
190
|
+
commit_md = f"[{commit}]({url})" if url else f"`{commit}`"
|
|
191
|
+
acked = " (acknowledged)" if f.ack else ""
|
|
192
|
+
out.append(f"**{f.verdict}**{acked} `{f.path}:{f.start}` | {f.kind} | risk {f.risk.value} "
|
|
193
|
+
f"({f.risk.band}) | grade {d.grade} | confidence {f.risk.confidence:.2f}")
|
|
194
|
+
out += ["", f"> {_reason(d).replace(chr(10), ' ')}", ""]
|
|
195
|
+
out.append(f"- Decision `{d.id}` ({d.type}) from {commit_md}")
|
|
196
|
+
out.append(f"- Evidence: {_md_evidence(d, 4)}")
|
|
197
|
+
out.append(f"- Factors: {_factors(f.risk)}")
|
|
198
|
+
if not f.ack and f.verdict == KEEP:
|
|
199
|
+
out.append(f"<sub>To proceed: `{ack_command(f, r.base, r.head is None)}`</sub>")
|
|
200
|
+
out.append("")
|
|
201
|
+
if len(r.findings) > 15:
|
|
202
|
+
out.append(f"... and {len(r.findings) - 15} more.")
|
|
203
|
+
out.append(f"<sub>{r.blocks_checked} change blocks in {r.files_checked} files against "
|
|
204
|
+
f"`{r.base[:12]}`. Repository text above is quoted evidence. "
|
|
205
|
+
f"{SCORE_NOTE}</sub>")
|
|
206
|
+
return "\n".join(out)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _md_evidence(d: DecisionRecord, n: int) -> str:
|
|
210
|
+
def one(e: Evidence) -> str:
|
|
211
|
+
label = f"{e.type} {e.ref[:12] if e.type in ('commit', 'revert') else e.ref}"
|
|
212
|
+
status = "" if e.retrieval_status == "ok" else f" ({e.retrieval_status.replace('_', ' ')})"
|
|
213
|
+
return (f"[{label}]({e.url})" if e.url else label) + status
|
|
214
|
+
return ", ".join(one(e) for e in d.evidence[:n]) or "none"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def why_markdown(r: WhyResult, ctx) -> str:
|
|
218
|
+
"""The same fields as the text output, for PR descriptions, issues and docs."""
|
|
219
|
+
t = r.target
|
|
220
|
+
span = f"{t.start}" if t.start == t.end else f"{t.start}-{t.end}"
|
|
221
|
+
sym = f" ({t.symbol})" if t.symbol else ""
|
|
222
|
+
out = [f"### Fence why: `{t.path}:{span}`{sym} @ `{t.rev[:12]}`", ""]
|
|
223
|
+
if r.unexplained_lines:
|
|
224
|
+
out += [f"{r.unexplained_lines} line(s) have no recoverable reason (grade D).", ""]
|
|
225
|
+
for d in r.decisions:
|
|
226
|
+
s = d.strength
|
|
227
|
+
url = ctx.commit_url(d.introduced_sha) if ctx else None
|
|
228
|
+
commit = d.introduced_sha[:12]
|
|
229
|
+
commit_md = f"[{commit}]({url})" if url else f"`{commit}`"
|
|
230
|
+
lines = ", ".join(f"{a.start_line}" + (f"-{a.end_line}" if a.end_line != a.start_line
|
|
231
|
+
else "") + (f" ({a.symbol})" if a.symbol else "")
|
|
232
|
+
for a in d.anchors)
|
|
233
|
+
out.append(f"**{s.band.upper()} {s.value}** {d.type} | grade {d.grade} | "
|
|
234
|
+
f"confidence {s.confidence:.2f} | `{d.id}`")
|
|
235
|
+
out += ["", f"> {_reason(d).replace(chr(10), ' ')}", ""]
|
|
236
|
+
out.append(f"- Introduced {_date(d.introduced_at)} by "
|
|
237
|
+
f"{d.evidence[0].author or 'unknown'} in {commit_md}")
|
|
238
|
+
out.append(f"- Lines: {lines}" + (f" | status {d.status}" if d.status != "active" else ""))
|
|
239
|
+
if d.constraints:
|
|
240
|
+
out.append("- Constraint: " + " | ".join(f'"{c}"' for c in d.constraints[:2]))
|
|
241
|
+
out.append(f"- Factors: {_factors(s)}")
|
|
242
|
+
out.append(f"- Evidence: {_md_evidence(d, 6)}")
|
|
243
|
+
if s.what_would_change:
|
|
244
|
+
out.append("- Would change this: " + "; ".join(s.what_would_change))
|
|
245
|
+
out.append("")
|
|
246
|
+
out += [f"Note: {x}" for x in r.notes]
|
|
247
|
+
out.append(f"<sub>Repository text above is quoted evidence. {SCORE_NOTE}</sub>")
|
|
248
|
+
return "\n".join(out)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# JSON ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def why_json(r: WhyResult) -> dict:
|
|
254
|
+
return {
|
|
255
|
+
"schema": SCHEMA, "kind": "why",
|
|
256
|
+
"target": asdict(r.target),
|
|
257
|
+
"decisions": [d.to_dict() for d in r.decisions],
|
|
258
|
+
"unexplained_lines": r.unexplained_lines,
|
|
259
|
+
"notes": r.notes, "score_note": SCORE_NOTE,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def finding_dict(f: Finding, base: str, head_is_base: bool) -> dict:
|
|
264
|
+
return {
|
|
265
|
+
"verdict": f.verdict, "path": f.path, "start": f.start, "end": f.end, "kind": f.kind,
|
|
266
|
+
"kind_reason": f.kind_reason, "risk": {**asdict(f.risk),
|
|
267
|
+
"top_factors": [asdict(x) for x in f.risk.top()]},
|
|
268
|
+
"decision": f.decision.to_dict(), "decision_fingerprint": f.decision_fingerprint,
|
|
269
|
+
"removed_preview": f.removed_preview, "lost_signals": list(f.lost_signals),
|
|
270
|
+
"ack": asdict(f.ack) if f.ack else None,
|
|
271
|
+
"ack_command": None if f.ack else ack_command(f, base, head_is_base),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def check_json(r: CheckResult) -> dict:
|
|
276
|
+
return {
|
|
277
|
+
"schema": SCHEMA, "kind": "check", "verdict": r.verdict, "exit_code": r.exit_code,
|
|
278
|
+
"base": r.base, "head": r.head, "bypassed": r.bypassed,
|
|
279
|
+
"files_checked": r.files_checked, "blocks_checked": r.blocks_checked,
|
|
280
|
+
"seconds": r.seconds, "notes": r.notes, "score_note": SCORE_NOTE,
|
|
281
|
+
"findings": [finding_dict(f, r.base, r.head is None) for f in r.findings],
|
|
282
|
+
"ghosts": [{"path": p, **asdict(g)} for p, g in r.ghosts],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def dumps(obj: object) -> str:
|
|
287
|
+
return json.dumps(obj, indent=2, sort_keys=False, ensure_ascii=False, default=str)
|
fence/report.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""`fence report`: where the load-bearing code is, for a tech lead (F6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from fence.context import Context
|
|
9
|
+
from fence.scan import scan
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build(ctx: Context, path: str = ".", *, limit: int = 15, max_files: int = 300) -> dict:
|
|
13
|
+
res = scan(ctx, path, max_files=max_files)
|
|
14
|
+
churn: Counter[str] = Counter()
|
|
15
|
+
owners: dict[str, Counter[str]] = {}
|
|
16
|
+
for row in ctx.store.conn.execute(
|
|
17
|
+
"SELECT f.path, c.author_email FROM file_changes f JOIN commits c ON c.sha = f.sha "
|
|
18
|
+
"WHERE c.orphaned = 0 AND c.format_only = 0"
|
|
19
|
+
):
|
|
20
|
+
churn[row["path"]] += 1
|
|
21
|
+
owners.setdefault(row["path"], Counter())[row["author_email"]] += 1
|
|
22
|
+
|
|
23
|
+
def item(f: str, d) -> dict:
|
|
24
|
+
return {"file": f, "lines": [[a.start_line, a.end_line] for a in d.anchors],
|
|
25
|
+
"strength": d.strength.value, "band": d.strength.band, "grade": d.grade,
|
|
26
|
+
"type": d.type, "decision": d.id, "reason": d.reason_text,
|
|
27
|
+
"commits_to_file": churn.get(f, 0)}
|
|
28
|
+
|
|
29
|
+
ranked = [item(f, d) for f, d in res.decisions]
|
|
30
|
+
fragile = [x for x in ranked if x["grade"] in ("C", "D")
|
|
31
|
+
and x["strength"] >= ctx.cfg.scoring.verify_min_risk]
|
|
32
|
+
hot = sorted((x for x in ranked if x["strength"] >= ctx.cfg.scoring.bands.medium),
|
|
33
|
+
key=lambda x: (-x["strength"] * x["commits_to_file"], x["file"]))
|
|
34
|
+
files = sorted({f for f, _ in res.decisions})
|
|
35
|
+
concentration = []
|
|
36
|
+
for f in files:
|
|
37
|
+
c = owners.get(f)
|
|
38
|
+
if not c or sum(c.values()) < 5:
|
|
39
|
+
continue
|
|
40
|
+
top, n = c.most_common(1)[0]
|
|
41
|
+
share = n / sum(c.values())
|
|
42
|
+
if share >= 0.8:
|
|
43
|
+
concentration.append({"file": f, "top_author_share": round(share, 2),
|
|
44
|
+
"commits": sum(c.values()), "authors": len(c)})
|
|
45
|
+
return {
|
|
46
|
+
"schema": "fence/v1", "kind": "report", "path": res.path,
|
|
47
|
+
"files_scanned": res.files_scanned, "decisions": len(ranked), "notes": res.notes,
|
|
48
|
+
"load_bearing": ranked[:limit], "fragile_undocumented": fragile[:limit],
|
|
49
|
+
"hot_and_strong": hot[:limit], "ownership_concentration": concentration[:limit],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_markdown(r: dict) -> str:
|
|
54
|
+
out = [f"# Fence report: `{r['path']}`", "",
|
|
55
|
+
f"{r['decisions']} decisions in {r['files_scanned']} files.", ""]
|
|
56
|
+
|
|
57
|
+
def table(title: str, rows: list[dict], cols: list[str]) -> None:
|
|
58
|
+
out.append(f"## {title}")
|
|
59
|
+
if not rows:
|
|
60
|
+
out.extend(["", "None.", ""])
|
|
61
|
+
return
|
|
62
|
+
out.extend(["", "| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)])
|
|
63
|
+
for x in rows:
|
|
64
|
+
vals = []
|
|
65
|
+
for c in cols:
|
|
66
|
+
v = x.get(c)
|
|
67
|
+
if c == "lines":
|
|
68
|
+
v = ",".join(f"{a}-{b}" for a, b in v[:3])
|
|
69
|
+
vals.append(str(v).replace("|", "\\|").replace("\n", " ")[:120])
|
|
70
|
+
out.append("| " + " | ".join(vals) + " |")
|
|
71
|
+
out.append("")
|
|
72
|
+
|
|
73
|
+
main = ["file", "lines", "strength", "grade", "type", "reason"]
|
|
74
|
+
table("Load-bearing code (do not simply delete)", r["load_bearing"], main)
|
|
75
|
+
table("Fragile and undocumented (strong, but reason only inferred or unknown)",
|
|
76
|
+
r["fragile_undocumented"], main)
|
|
77
|
+
table("Strong and frequently changed", r["hot_and_strong"], [*main[:4], "commits_to_file"])
|
|
78
|
+
table("Ownership concentration (>= 80% of commits by one person)",
|
|
79
|
+
r["ownership_concentration"], ["file", "top_author_share", "commits", "authors"])
|
|
80
|
+
out += [f"Note: {n}" for n in r["notes"]]
|
|
81
|
+
return "\n".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_html(r: dict) -> str:
|
|
85
|
+
md = to_markdown(r)
|
|
86
|
+
body = []
|
|
87
|
+
for line in md.splitlines():
|
|
88
|
+
e = html.escape(line)
|
|
89
|
+
if line.startswith("# "):
|
|
90
|
+
body.append(f"<h1>{e[2:]}</h1>")
|
|
91
|
+
elif line.startswith("## "):
|
|
92
|
+
body.append(f"<h2>{e[3:]}</h2>")
|
|
93
|
+
elif line.startswith("|---"):
|
|
94
|
+
continue
|
|
95
|
+
elif line.startswith("| "):
|
|
96
|
+
cells = [html.escape(c.strip()) for c in line.strip("|").split(" | ")]
|
|
97
|
+
body.append("<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>")
|
|
98
|
+
elif line:
|
|
99
|
+
body.append(f"<p>{e}</p>")
|
|
100
|
+
return ("<!doctype html><meta charset=utf-8><title>Fence report</title>"
|
|
101
|
+
"<style>body{font:14px system-ui;margin:2em;max-width:1100px}"
|
|
102
|
+
"td{border-bottom:1px solid #ddd;padding:4px 8px;vertical-align:top}</style>"
|
|
103
|
+
+ "\n".join(body))
|
fence/scan.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""`fence scan DIR`: every decision in a directory, ranked by "do not simply delete"."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from fence.context import Context
|
|
8
|
+
from fence.decisions.build import WhyError, why
|
|
9
|
+
from fence.decisions.model import DecisionRecord
|
|
10
|
+
from fence.git.runner import GitError
|
|
11
|
+
from fence.globs import match_any
|
|
12
|
+
from fence.lang import plugin_for
|
|
13
|
+
|
|
14
|
+
MAX_FILES = 300
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ScanResult:
|
|
19
|
+
path: str
|
|
20
|
+
decisions: list[tuple[str, DecisionRecord]] = field(default_factory=list) # (file, record)
|
|
21
|
+
files_scanned: int = 0
|
|
22
|
+
files_skipped: int = 0
|
|
23
|
+
notes: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scan(ctx: Context, path: str = ".", *, rev: str = "HEAD", max_files: int = MAX_FILES,
|
|
27
|
+
deep: bool = False) -> ScanResult:
|
|
28
|
+
rev_sha = ctx.repo.verify_ref(rev)
|
|
29
|
+
rel = "." if path in (".", "") else ctx.repo.safe_relpath(path)
|
|
30
|
+
out = ctx.repo.run(["ls-tree", "-r", "--name-only", "-z", rev_sha, "--", rel])
|
|
31
|
+
files = [f for f in out.split("\x00") if f]
|
|
32
|
+
files = [f for f in files if not match_any(f, ctx.cfg.index.exclude)
|
|
33
|
+
and not match_any(f, ctx.cfg.policy.ignore_paths)]
|
|
34
|
+
# Source files first; then others. Stable order.
|
|
35
|
+
files.sort(key=lambda f: (plugin_for(f) is None, f))
|
|
36
|
+
res = ScanResult(rel)
|
|
37
|
+
if len(files) > max_files:
|
|
38
|
+
res.notes.append(f"{len(files)} files; scanned the first {max_files} (source files first)")
|
|
39
|
+
res.files_skipped = len(files) - max_files
|
|
40
|
+
files = files[:max_files]
|
|
41
|
+
for f in files:
|
|
42
|
+
try:
|
|
43
|
+
w = why(ctx, f, rev=rev_sha, deep=deep)
|
|
44
|
+
except (WhyError, GitError):
|
|
45
|
+
res.files_skipped += 1
|
|
46
|
+
continue
|
|
47
|
+
res.files_scanned += 1
|
|
48
|
+
res.decisions += [(f, d) for d in w.decisions if d.type != "cleanup"]
|
|
49
|
+
res.decisions.sort(key=lambda fd: (-fd[1].strength.value, fd[0], fd[1].id))
|
|
50
|
+
return res
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Outcome calibration: Change Risk -> observed regret rate, from backtest data.
|
|
2
|
+
|
|
3
|
+
The table is fitted offline with isotonic regression (pool-adjacent-violators) on
|
|
4
|
+
backtest outcomes and shipped as static data (calibration.json). Nothing is
|
|
5
|
+
computed at check time, so results stay deterministic. Bins are merged until each
|
|
6
|
+
holds at least MIN_BIN blocks and MIN_POSITIVE regrets (except a trailing all-zero
|
|
7
|
+
bin), so no rate rests on a handful of cases. Equal neighbouring rates are merged,
|
|
8
|
+
which makes rates strictly increasing with risk.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
TABLE = Path(__file__).with_name("calibration.json")
|
|
20
|
+
MIN_BIN = 30
|
|
21
|
+
MIN_POSITIVE = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Bin:
|
|
26
|
+
risk_min: int
|
|
27
|
+
risk_max: int
|
|
28
|
+
n: int
|
|
29
|
+
regretted: int
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def rate(self) -> float:
|
|
33
|
+
return self.regretted / self.n if self.n else 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _merge(a: Bin, b: Bin) -> Bin:
|
|
37
|
+
return Bin(a.risk_min, b.risk_max, a.n + b.n, a.regretted + b.regretted)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _pool(bins: list[Bin]) -> list[Bin]:
|
|
41
|
+
"""Pool adjacent violators: merge until rates strictly increase."""
|
|
42
|
+
out: list[Bin] = []
|
|
43
|
+
for b in bins:
|
|
44
|
+
out.append(b)
|
|
45
|
+
while len(out) >= 2 and out[-2].rate >= out[-1].rate:
|
|
46
|
+
hi, lo = out.pop(), out.pop()
|
|
47
|
+
out.append(_merge(lo, hi))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fit(points: Iterable[tuple[int, bool]]) -> list[Bin]:
|
|
52
|
+
"""Isotonic fit of P(regret) over integer Change Risk, as merged bins."""
|
|
53
|
+
by_risk: dict[int, list[int]] = {}
|
|
54
|
+
for risk, regretted in points:
|
|
55
|
+
c = by_risk.setdefault(int(risk), [0, 0])
|
|
56
|
+
c[0] += 1
|
|
57
|
+
c[1] += int(bool(regretted))
|
|
58
|
+
bins = _pool([Bin(r, r, n, k) for r, (n, k) in sorted(by_risk.items())])
|
|
59
|
+
# Minimum support: grow each bin until it has MIN_BIN blocks and MIN_POSITIVE regrets.
|
|
60
|
+
supported: list[Bin] = []
|
|
61
|
+
cur: Bin | None = None
|
|
62
|
+
for b in bins:
|
|
63
|
+
cur = b if cur is None else _merge(cur, b)
|
|
64
|
+
if cur.n >= MIN_BIN and cur.regretted >= MIN_POSITIVE:
|
|
65
|
+
supported.append(cur)
|
|
66
|
+
cur = None
|
|
67
|
+
if cur is not None:
|
|
68
|
+
if supported:
|
|
69
|
+
supported[-1] = _merge(supported[-1], cur)
|
|
70
|
+
else:
|
|
71
|
+
supported.append(cur)
|
|
72
|
+
out = _pool(supported)
|
|
73
|
+
# Cover 0-100 with no gaps: each bin runs up to the next bin's start.
|
|
74
|
+
for i, b in enumerate(out):
|
|
75
|
+
lo = 0 if i == 0 else b.risk_min
|
|
76
|
+
hi = 100 if i == len(out) - 1 else out[i + 1].risk_min - 1
|
|
77
|
+
out[i] = Bin(lo, hi, b.n, b.regretted)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def write(bins: list[Bin], path: Path, provenance: dict) -> None:
|
|
82
|
+
data = {"provenance": provenance,
|
|
83
|
+
"bins": [{"risk_min": b.risk_min, "risk_max": b.risk_max, "n": b.n,
|
|
84
|
+
"regretted": b.regretted, "rate": round(b.rate, 4)} for b in bins]}
|
|
85
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@lru_cache(maxsize=4)
|
|
89
|
+
def load(path: str = str(TABLE)) -> tuple[tuple[Bin, ...], dict]:
|
|
90
|
+
p = Path(path)
|
|
91
|
+
if not p.is_file():
|
|
92
|
+
return (), {}
|
|
93
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
94
|
+
bins = tuple(Bin(b["risk_min"], b["risk_max"], b["n"], b["regretted"])
|
|
95
|
+
for b in data.get("bins", []))
|
|
96
|
+
return bins, data.get("provenance", {})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lookup(risk: int) -> Bin | None:
|
|
100
|
+
"""The calibration bin for a Change Risk value, or None if no table is shipped."""
|
|
101
|
+
bins, _ = load()
|
|
102
|
+
for b in bins:
|
|
103
|
+
if b.risk_min <= risk <= b.risk_max:
|
|
104
|
+
return b
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> None:
|
|
109
|
+
"""python -m fence.scoring.calibration OUT.json DUMP.jsonl... (backtest --dump files)."""
|
|
110
|
+
import sys
|
|
111
|
+
from datetime import date
|
|
112
|
+
|
|
113
|
+
from fence import __version__
|
|
114
|
+
|
|
115
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
116
|
+
out, dumps = Path(args[0]), [Path(a) for a in args[1:]]
|
|
117
|
+
points = []
|
|
118
|
+
for d in dumps:
|
|
119
|
+
for line in d.read_text(encoding="utf-8").splitlines():
|
|
120
|
+
r = json.loads(line)
|
|
121
|
+
points.append((r["risk"], r["regretted"]))
|
|
122
|
+
bins = fit(points)
|
|
123
|
+
write(bins, out, {"fitted_on": [d.stem for d in dumps], "blocks": len(points),
|
|
124
|
+
"regretted": sum(k for _, k in points), "date": date.today().isoformat(),
|
|
125
|
+
"fence_version": __version__, "method": "isotonic (PAV), min support "
|
|
126
|
+
f"{MIN_BIN} blocks / {MIN_POSITIVE} regrets per bin"})
|
|
127
|
+
for b in bins:
|
|
128
|
+
print(f"risk {b.risk_min:>3}-{b.risk_max:<3} n={b.n:<5} regretted={b.regretted:<3} "
|
|
129
|
+
f"rate={b.rate:.3f}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|