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/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()