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/cli.py ADDED
@@ -0,0 +1,576 @@
1
+ """Typer entry points. Exit codes: 0 ok, 1 error, 2 policy threshold exceeded,
2
+ 3 acknowledgement required, 4 index unavailable."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from fence import __version__
15
+ from fence.check.diff import DiffError, DiffSpec, merge_base
16
+ from fence.check.engine import EXIT_ERROR, EXIT_NO_INDEX, _decision_fingerprint, check
17
+ from fence.config import ConfigError, load_config
18
+ from fence.context import Context
19
+ from fence.decisions.build import WhyError, why
20
+ from fence.git.runner import GitError
21
+ from fence.index.formatting import detect_format_only
22
+ from fence.index.indexer import index_repo, needs_index
23
+ from fence.index.store import IndexUnavailable, db_path_for
24
+ from fence.policy import acks as ackmod
25
+ from fence.render import Out, check_json, check_text, dumps, why_json, why_markdown, why_text
26
+
27
+ app = typer.Typer(add_completion=False, no_args_is_help=True,
28
+ help="Fence: know why code exists before you remove it.")
29
+
30
+ RepoOpt = Annotated[Path, typer.Option("--repo", "-C", help="Repository path.")]
31
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output (schema fence/v1).")]
32
+ QuietOpt = Annotated[bool, typer.Option("--quiet", "-q", help="Only the exit code and errors.")]
33
+ NoColorOpt = Annotated[bool, typer.Option("--no-color", help="Never colour output.")]
34
+ VerboseOpt = Annotated[bool, typer.Option("--verbose", "-v",
35
+ help="Show every factor and evidence.")]
36
+
37
+ _TARGET = re.compile(r"^(?P<path>.+?):(?P<start>\d+)(?:-(?P<end>\d+))?$")
38
+
39
+
40
+ @app.callback()
41
+ def main() -> None:
42
+ """Fence: know why code exists before you remove it."""
43
+ # An explicit callback keeps Typer in multi-command mode, so `fence version`
44
+ # stays a subcommand rather than becoming the root command.
45
+
46
+
47
+ def _fail(msg: str, code: int = EXIT_ERROR) -> typer.Exit:
48
+ typer.echo(f"fence: {msg}", err=True)
49
+ return typer.Exit(code)
50
+
51
+
52
+ def _open(repo: Path, *, create: bool = True) -> Context:
53
+ try:
54
+ return Context.open(repo, create=create)
55
+ except ConfigError as e:
56
+ raise _fail(str(e)) from None
57
+ except IndexUnavailable as e:
58
+ raise _fail(str(e), EXIT_NO_INDEX) from None
59
+ except GitError as e:
60
+ raise _fail(f"{e} (is {repo} inside a git repository?)") from None
61
+
62
+
63
+ def _out(ctx: Context, no_color: bool, verbose: bool) -> Out:
64
+ color = "never" if no_color else ctx.cfg.output.color
65
+ return Out(color, max_items=ctx.cfg.output.max_items, verbose=verbose)
66
+
67
+
68
+ def parse_target(target: str) -> tuple[str, int | None, int | None]:
69
+ m = _TARGET.match(target)
70
+ if not m:
71
+ return target, None, None
72
+ start = int(m["start"])
73
+ return m["path"], start, int(m["end"]) if m["end"] else None
74
+
75
+
76
+ def _ensure_indexed(ctx: Context, quiet: bool) -> None:
77
+ """Index first when needed, so `fence why` works in one step and never explains new
78
+ commits from their subject line alone."""
79
+ if needs_index(ctx.repo, ctx.store, ctx.cfg):
80
+ if not quiet:
81
+ empty = ctx.store.commit_count(include_orphaned=True) == 0
82
+ typer.echo("fence: no index yet; indexing history first..." if empty
83
+ else "fence: indexing new commits...", err=True)
84
+ index_repo(ctx.repo, ctx.store, ctx.cfg, detect_format=detect_format_only)
85
+
86
+
87
+ @app.command()
88
+ def version() -> None:
89
+ """Print the Fence version."""
90
+ typer.echo(__version__)
91
+
92
+
93
+ @app.command()
94
+ def index(
95
+ repo: RepoOpt = Path("."), reindex: Annotated[bool, typer.Option(
96
+ "--reindex", help="Delete the index and rebuild it from scratch.")] = False,
97
+ json_out: JsonOpt = False, quiet: QuietOpt = False,
98
+ ) -> None:
99
+ """Index history incrementally (only new commits are processed)."""
100
+ if reindex:
101
+ ctx = _open(repo)
102
+ path = ctx.store.path
103
+ ctx.close()
104
+ if path:
105
+ for suffix in ("", "-wal", "-shm"):
106
+ Path(str(path) + suffix).unlink(missing_ok=True)
107
+ ctx = _open(repo)
108
+
109
+ def progress(n: int) -> None:
110
+ if not quiet and not json_out:
111
+ typer.echo(f" indexed {n} commits...", err=True)
112
+
113
+ try:
114
+ rep = index_repo(ctx.repo, ctx.store, ctx.cfg, detect_format=detect_format_only,
115
+ progress=progress)
116
+ except GitError as e:
117
+ raise _fail(str(e)) from None
118
+ from fence.index.ghosts import scan_tombstones
119
+
120
+ try:
121
+ rep.notes.append(f"{scan_tombstones(ctx.repo, ctx.store, ctx.cfg)} new ghost-fence "
122
+ "tombstones recorded")
123
+ except GitError as e:
124
+ rep.notes.append(f"ghost-fence scan skipped: {e}")
125
+ gh = None
126
+ if ctx.github_token and ctx.slug:
127
+ from fence.github.sync import sync
128
+
129
+ gh = sync(ctx)
130
+ if json_out:
131
+ typer.echo(dumps({"schema": "fence/v1", "kind": "index", **rep.__dict__,
132
+ "github": gh.__dict__ if gh else None}))
133
+ return
134
+ if gh and not quiet:
135
+ typer.echo(f"GitHub: {gh.prs} PRs, {gh.issues} issues, {gh.review_comments} review "
136
+ f"comments fetched ({gh.requests} requests)"
137
+ + (f"; stopped: {gh.stopped}" if gh.stopped else ""))
138
+ if quiet:
139
+ return
140
+ typer.echo(f"Indexed {rep.indexed} new commit(s) in {rep.seconds:.1f}s; "
141
+ f"{rep.total} in index.")
142
+ for n in rep.notes:
143
+ typer.echo(f"Note: {n}")
144
+ if ctx.github_token is None:
145
+ typer.echo("Note: no GitHub token found; running in local-git mode. PR, issue and "
146
+ f"review text are unavailable (set {ctx.cfg.github.token_env}).")
147
+
148
+
149
+ @app.command("why")
150
+ def why_cmd(
151
+ target: Annotated[str, typer.Argument(help="FILE, FILE:LINE or FILE:START-END")],
152
+ symbol: Annotated[str | None, typer.Option("--symbol", "-s",
153
+ help="Function/class name.")] = None,
154
+ rev: Annotated[str, typer.Option(help="Revision to explain.")] = "HEAD",
155
+ newest: Annotated[bool, typer.Option("--newest", help="Newest first, not strongest.")] = False,
156
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False, quiet: QuietOpt = False,
157
+ no_color: NoColorOpt = False, verbose: VerboseOpt = False,
158
+ fmt: Annotated[str, typer.Option("--format", help="text | json | markdown")] = "text",
159
+ ) -> None:
160
+ """Why does this code exist? Evidence-backed, graded decision records."""
161
+ if fmt not in ("text", "json", "markdown"):
162
+ raise _fail(f"unknown --format {fmt!r}: use text, json or markdown")
163
+ fmt = "json" if json_out else fmt
164
+ ctx = _open(repo)
165
+ _ensure_indexed(ctx, quiet or fmt != "text")
166
+ path, start, end = parse_target(target)
167
+ try:
168
+ res = why(ctx, path, start, end, symbol=symbol, rev=rev)
169
+ except (WhyError, GitError) as e:
170
+ raise _fail(str(e)) from None
171
+ if newest:
172
+ res.decisions.sort(key=lambda d: (-d.introduced_at, d.id))
173
+ if quiet:
174
+ return
175
+ typer.echo(dumps(why_json(res)) if fmt == "json" else why_markdown(res, ctx)
176
+ if fmt == "markdown" else why_text(res, _out(ctx, no_color, verbose)))
177
+
178
+
179
+ @app.command("explain")
180
+ def explain_cmd(
181
+ target: Annotated[str, typer.Argument(help="FILE:LINE")],
182
+ rev: Annotated[str, typer.Option(help="Revision to explain.")] = "HEAD",
183
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False, no_color: NoColorOpt = False,
184
+ ) -> None:
185
+ """Show how every score for a line was derived: all factors and all evidence."""
186
+ ctx = _open(repo)
187
+ _ensure_indexed(ctx, json_out)
188
+ path, start, end = parse_target(target)
189
+ try:
190
+ res = why(ctx, path, start, end, rev=rev)
191
+ except (WhyError, GitError) as e:
192
+ raise _fail(str(e)) from None
193
+ if json_out:
194
+ typer.echo(dumps(why_json(res)))
195
+ return
196
+ o = _out(ctx, no_color, True)
197
+ typer.echo(why_text(res, o, full=True))
198
+ typer.echo("\nScore = clamp(sum of factor points, 0, 100). Bands: "
199
+ f"medium >= {ctx.cfg.scoring.bands.medium}, high >= {ctx.cfg.scoring.bands.high},"
200
+ f" critical >= {ctx.cfg.scoring.bands.critical}. Confidence starts from the "
201
+ "evidence grade and drops for shallow clones, missing index data and unfetched "
202
+ "PRs.")
203
+
204
+
205
+ @app.command("check")
206
+ def check_cmd(
207
+ base: Annotated[str | None, typer.Option(help="Base revision (default HEAD).")] = None,
208
+ head: Annotated[str | None, typer.Option(help="Head revision; omit for the working tree.")]
209
+ = None,
210
+ staged: Annotated[bool, typer.Option("--staged", help="Check staged changes only.")] = False,
211
+ pr: Annotated[bool, typer.Option(
212
+ "--pr", help="Compare HEAD with the merge-base of --base (like a pull request).")] = False,
213
+ diff_file: Annotated[Path | None, typer.Option(
214
+ "--diff", help="Check a unified diff file ('-' for stdin) against --base.")] = None,
215
+ hook: Annotated[bool, typer.Option(
216
+ "--hook", help="Git-hook mode: fast, never auto-indexes, fails open on errors.")] = False,
217
+ fmt: Annotated[str, typer.Option("--format", help="text | json | markdown")] = "text",
218
+ output: Annotated[Path | None, typer.Option(
219
+ "--output", "-o", help="Also write the report (in --format) to this file.")] = None,
220
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False, quiet: QuietOpt = False,
221
+ no_color: NoColorOpt = False, verbose: VerboseOpt = False,
222
+ ) -> None:
223
+ """The fence check: is this change removing or weakening code that exists for a reason?"""
224
+ from fence.render import check_markdown
225
+
226
+ if json_out:
227
+ fmt = "json"
228
+ try:
229
+ ctx = Context.open(repo, create=not hook)
230
+ except (GitError, ConfigError, IndexUnavailable) as e:
231
+ if hook:
232
+ typer.echo(f"fence: skipped ({e})", err=True)
233
+ raise typer.Exit(0) from None
234
+ raise _fail(str(e), EXIT_NO_INDEX if isinstance(e, IndexUnavailable) else EXIT_ERROR) \
235
+ from None
236
+ if not hook:
237
+ _ensure_indexed(ctx, quiet or fmt != "text")
238
+ try:
239
+ if diff_file is not None:
240
+ text = sys.stdin.read() if str(diff_file) == "-" else diff_file.read_text("utf-8")
241
+ res = check(ctx, diff_text=text, base=base or "HEAD", deep=not hook)
242
+ else:
243
+ b = base or "HEAD"
244
+ h = head
245
+ if pr:
246
+ h = head or "HEAD"
247
+ b = merge_base(ctx.repo, b, h)
248
+ res = check(ctx, DiffSpec(base=b, head=h, staged=staged), deep=not hook)
249
+ except (DiffError, GitError, OSError) as e:
250
+ if hook:
251
+ typer.echo(f"fence: skipped ({e})", err=True)
252
+ raise typer.Exit(0) from None
253
+ raise _fail(str(e)) from None
254
+ report = (dumps(check_json(res)) if fmt == "json" else check_markdown(res, ctx)
255
+ if fmt == "markdown" else check_text(res, _out(ctx, no_color, verbose)))
256
+ if output:
257
+ output.write_text(report + "\n", encoding="utf-8")
258
+ if hook and res.exit_code == 0:
259
+ raise typer.Exit(0) # silent when nothing blocks
260
+ if not quiet:
261
+ typer.echo(report)
262
+ if hook and res.exit_code:
263
+ typer.echo("fence: commit blocked. Acknowledge with the `fence ack` command above, "
264
+ f"or bypass once with {ctx.cfg.policy.allow_bypass_env}=1.", err=True)
265
+ raise typer.Exit(res.exit_code)
266
+
267
+
268
+ HOOK_MARK = "# installed by fence"
269
+
270
+
271
+ @app.command("hook")
272
+ def hook_cmd(
273
+ action: Annotated[str, typer.Argument(help="install | uninstall")],
274
+ pre_push: Annotated[bool, typer.Option("--pre-push", help="Also check on push.")] = False,
275
+ force: Annotated[bool, typer.Option(help="Replace an existing non-fence hook.")] = False,
276
+ repo: RepoOpt = Path("."),
277
+ ) -> None:
278
+ """Install or remove the fence git hooks (pre-commit, optionally pre-push)."""
279
+ ctx = _open(repo)
280
+ hooks_dir = Path(ctx.repo.run(["rev-parse", "--git-path", "hooks"]).strip())
281
+ if not hooks_dir.is_absolute():
282
+ hooks_dir = ctx.repo.root / hooks_dir
283
+ scripts = {"pre-commit": "check --staged --hook"}
284
+ if pre_push:
285
+ scripts["pre-push"] = 'check --hook --pr --base "@{upstream}"'
286
+ # Git runs hooks with its own sh and PATH. On Windows a venv's Scripts folder is often
287
+ # not on it, and a hook that silently found no fence would check nothing. So record
288
+ # the fence that installed the hook, fall back to PATH, and say so when neither exists.
289
+ running = Path(sys.argv[0]).stem in ("fence", "gitfence")
290
+ exe = (shutil.which(sys.argv[0]) if running else None) or shutil.which("fence") or ""
291
+ exe = Path(exe).resolve().as_posix().replace("'", "'\\''") if exe else ""
292
+ for name, cmd in scripts.items():
293
+ target = hooks_dir / name
294
+ existing = target.read_text(encoding="utf-8", errors="replace") if target.exists() else ""
295
+ if action == "uninstall":
296
+ if HOOK_MARK in existing:
297
+ target.unlink()
298
+ typer.echo(f"removed {target}")
299
+ continue
300
+ if action != "install":
301
+ raise _fail("action must be install or uninstall")
302
+ if existing and HOOK_MARK not in existing and not force:
303
+ raise _fail(f"{target} exists and is not a fence hook; use --force to replace it")
304
+ hooks_dir.mkdir(parents=True, exist_ok=True)
305
+ target.write_text(
306
+ f"#!/bin/sh\n{HOOK_MARK}; `fence hook uninstall` removes it\n"
307
+ f"FENCE='{exe}'\n"
308
+ '[ -n "$FENCE" ] && [ -x "$FENCE" ] || FENCE=$(command -v fence) || {\n'
309
+ ' echo "fence: not found, so this hook checked nothing;'
310
+ ' re-run fence hook install" >&2\n'
311
+ " exit 0\n"
312
+ "}\n"
313
+ f'exec "$FENCE" {cmd}\n', encoding="utf-8", newline="\n")
314
+ target.chmod(0o755)
315
+ typer.echo(f"installed {target}")
316
+
317
+
318
+ @app.command("ack")
319
+ def ack_cmd(
320
+ decision_id: Annotated[str, typer.Argument(help="Decision id, e.g. D3f9a1c2b7e")],
321
+ at: Annotated[str, typer.Option("--at", help="FILE:LINE the decision covers.")],
322
+ reason: Annotated[str, typer.Option("--reason", "-m", help="Why it is safe to proceed.")],
323
+ rev: Annotated[str, typer.Option(help="Revision the line number refers to.")] = "HEAD",
324
+ expires: Annotated[int | None, typer.Option(help="Days until it expires (default from "
325
+ "policy.ack_expiry_days).")] = None,
326
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False,
327
+ ) -> None:
328
+ """Record "I know why, proceed" in the team ledger (.fence/acks.jsonl)."""
329
+ ctx = _open(repo)
330
+ path, start, end = parse_target(at)
331
+ if start is None:
332
+ raise _fail("--at needs FILE:LINE")
333
+ try:
334
+ res = why(ctx, path, start, end, rev=rev, deep=False)
335
+ except (WhyError, GitError) as e:
336
+ raise _fail(str(e)) from None
337
+ match = next((d for d in res.decisions if d.id == decision_id), None)
338
+ if match is None:
339
+ ids = ", ".join(d.id for d in res.decisions) or "none"
340
+ raise _fail(f"{decision_id} does not cover {at} at {rev} (decisions there: {ids})")
341
+ text = ctx.repo.read_blob(res.target.rev, res.target.path)
342
+ fp = _decision_fingerprint(res.target.path, text.split("\n"), match)
343
+ try:
344
+ who = ctx.repo.run(["config", "user.email"]).strip() or "unknown"
345
+ except GitError:
346
+ who = "unknown"
347
+ try:
348
+ ack = ackmod.append(ctx.repo.root / ctx.cfg.policy.ack_ledger, decision_id=decision_id,
349
+ path=res.target.path, fingerprint=fp, reason=reason, who=who,
350
+ expiry_days=expires or ctx.cfg.policy.ack_expiry_days)
351
+ except ackmod.AckError as e:
352
+ raise _fail(str(e)) from None
353
+ if json_out:
354
+ typer.echo(dumps({"schema": "fence/v1", "kind": "ack", **ack.__dict__}))
355
+ else:
356
+ typer.echo(f"Acknowledged {decision_id} for {res.target.path}"
357
+ + (f" until {ack.expires[:10]}" if ack.expires else "")
358
+ + f". Commit {ctx.cfg.policy.ack_ledger} to share it.")
359
+
360
+
361
+ @app.command("doctor")
362
+ def doctor_cmd(repo: RepoOpt = Path("."), json_out: JsonOpt = False) -> None:
363
+ """Diagnose the environment, index health and coverage."""
364
+ checks: list[tuple[str, bool, str]] = []
365
+ git = shutil.which("git")
366
+ checks.append(("git", bool(git), git or "git not found on PATH"))
367
+ try:
368
+ ctx = Context.open(repo)
369
+ except (GitError, ConfigError, IndexUnavailable) as e:
370
+ checks.append(("repository", False, str(e)))
371
+ _doctor_print(checks, json_out)
372
+ raise typer.Exit(EXIT_ERROR) from None
373
+ checks.append(("repository", True, str(ctx.repo.root)))
374
+ checks.append(("config", True, ctx.cfg.source or "<defaults>"))
375
+ n = ctx.store.commit_count()
376
+ checks.append(("index", n > 0, f"{n} commits at {ctx.store.path}" if n else
377
+ "empty: run `fence index`"))
378
+ checks.append(("index integrity", ctx.store.integrity_ok(), "PRAGMA quick_check"))
379
+ stale = [name for name, sha in ctx.store.refs().items()
380
+ if _safe_ref(ctx, name) not in (None, sha)]
381
+ freshness = "up to date" if not stale else f"behind on {', '.join(stale)}: run `fence index`"
382
+ checks.append(("index freshness", not stale, freshness))
383
+ checks.append(("history depth", not ctx.shallow,
384
+ "full history" if not ctx.shallow else "shallow clone: older reasons invisible"))
385
+ token = ctx.github_token
386
+ gh = (f"token found in {ctx.cfg.github.token_env}" if token
387
+ else f"no token ({ctx.cfg.github.token_env}): local-git mode")
388
+ gh += f"; remote {ctx.slug[0]}/{ctx.slug[1]}" if ctx.slug else "; no GitHub remote"
389
+ checks.append(("github", True, gh))
390
+ _, problems = ackmod.load(ctx.repo.root / ctx.cfg.policy.ack_ledger)
391
+ checks.append(("ack ledger", not problems, "; ".join(problems) or "ok"))
392
+ from fence.lang import supported_languages
393
+
394
+ checks.append(("languages", True, ", ".join(supported_languages())))
395
+ _doctor_print(checks, json_out)
396
+ if not all(ok for name, ok, _ in checks if name in ("git", "index integrity")):
397
+ raise typer.Exit(EXIT_ERROR)
398
+
399
+
400
+ def _safe_ref(ctx: Context, name: str) -> str | None:
401
+ try:
402
+ return ctx.repo.verify_ref(name)
403
+ except GitError:
404
+ return None
405
+
406
+
407
+ def _doctor_print(checks: list[tuple[str, bool, str]], json_out: bool) -> None:
408
+ if json_out:
409
+ typer.echo(dumps({"schema": "fence/v1", "kind": "doctor",
410
+ "checks": [{"name": n, "ok": ok, "detail": d} for n, ok, d in checks]}))
411
+ return
412
+ for name, ok, detail in checks:
413
+ typer.echo(f"{'ok ' if ok else 'WARN'} {name:<16} {detail}")
414
+
415
+
416
+ @app.command("config")
417
+ def config_cmd(repo: RepoOpt = Path("."), json_out: JsonOpt = False) -> None:
418
+ """Show the effective configuration (file values over defaults) and where it came from."""
419
+ try:
420
+ root = Context.open(repo).repo.root
421
+ cfg = load_config(root)
422
+ except (ConfigError, GitError, IndexUnavailable) as e:
423
+ raise _fail(str(e)) from None
424
+ data = cfg.model_dump(mode="json")
425
+ if json_out:
426
+ typer.echo(dumps(data))
427
+ return
428
+ typer.echo(f"# source: {data.pop('source')}")
429
+ typer.echo(f"# index: {db_path_for(cfg.cache_dir, root)}")
430
+ for section, values in data.items():
431
+ typer.echo(f"\n[{section}]")
432
+ for k, v in values.items():
433
+ typer.echo(f"{k} = {dumps(v) if not isinstance(v, str) else repr(v)}")
434
+
435
+
436
+ @app.command("mcp")
437
+ def mcp_cmd(repo: RepoOpt = Path(".")) -> None:
438
+ """Run the MCP server on stdio (for Claude Code and other agents)."""
439
+ from fence.mcp.server import serve
440
+
441
+ serve(str(repo))
442
+
443
+
444
+ @app.command("scan")
445
+ def scan_cmd(
446
+ path: Annotated[str, typer.Argument(help="Directory (default: repo root).")] = ".",
447
+ limit: Annotated[int, typer.Option(help="Max decisions to show.")] = 20,
448
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False, no_color: NoColorOpt = False,
449
+ ) -> None:
450
+ """List everything in a directory you should NOT simply delete, strongest first."""
451
+ from fence.scan import scan
452
+
453
+ ctx = _open(repo)
454
+ _ensure_indexed(ctx, json_out)
455
+ try:
456
+ res = scan(ctx, path)
457
+ except GitError as e:
458
+ raise _fail(str(e)) from None
459
+ if json_out:
460
+ typer.echo(dumps({"schema": "fence/v1", "kind": "scan", "path": res.path,
461
+ "files_scanned": res.files_scanned, "notes": res.notes,
462
+ "decisions": [{"file": f, **d.to_dict()} for f, d in
463
+ res.decisions[:limit]]}))
464
+ return
465
+ o = _out(ctx, no_color, False)
466
+ typer.echo(f"{res.path}: {len(res.decisions)} decisions in {res.files_scanned} files")
467
+ for f, d in res.decisions[:limit]:
468
+ s = d.strength
469
+ lines = ",".join(f"{a.start_line}-{a.end_line}" for a in d.anchors[:3])
470
+ typer.echo(f"{o.c(f'[{s.band.upper()} {s.value}]', s.band)} {f}:{lines} | {d.type} | "
471
+ f"grade {d.grade} | {d.reason_text[:100]}")
472
+ for n in res.notes:
473
+ typer.echo(f"Note: {n}")
474
+
475
+
476
+ if __name__ == "__main__":
477
+ app()
478
+
479
+
480
+ @app.command("backtest")
481
+ def backtest_cmd(
482
+ max_commits: Annotated[int, typer.Option(help="Commits to replay.")] = 300,
483
+ window: Annotated[int, typer.Option(help="Later commits in which a regret can appear.")]
484
+ = 200,
485
+ rev: Annotated[str, typer.Option(help="Newest revision of the replayed range.")] = "HEAD",
486
+ fast: Annotated[bool, typer.Option("--fast", help="Skip line-history walks.")] = False,
487
+ dump: Annotated[Path | None, typer.Option(help="Write every block outcome as JSONL.")]
488
+ = None,
489
+ repo: RepoOpt = Path("."), json_out: JsonOpt = False,
490
+ ) -> None:
491
+ """Removal-Regret Backtest: would Fence have warned about removals later undone?"""
492
+ from fence.eval.backtest import backtest
493
+
494
+ ctx = _open(repo)
495
+ if not json_out:
496
+ typer.echo("fence: indexing...", err=True)
497
+ index_repo(ctx.repo, ctx.store, ctx.cfg, detect_format=detect_format_only)
498
+
499
+ def progress(n: int, total: int) -> None:
500
+ if not json_out:
501
+ typer.echo(f" replayed {n}/{total} commits...", err=True)
502
+
503
+ try:
504
+ rep = backtest(ctx, rev=rev, max_commits=max_commits, window=window, deep=not fast,
505
+ progress=progress)
506
+ except GitError as e:
507
+ raise _fail(str(e)) from None
508
+ s = rep.summary()
509
+ if dump:
510
+ import json
511
+
512
+ dump.write_text("".join(json.dumps(o.__dict__) + "\n" for o in rep.outcomes),
513
+ encoding="utf-8")
514
+ if json_out:
515
+ examples = [o.__dict__ for o in rep.outcomes if o.regretted][:20]
516
+ typer.echo(dumps({"schema": "fence/v1", "kind": "backtest", **s, "regretted_examples":
517
+ examples}))
518
+ return
519
+ for k, v in s.items():
520
+ if k != "calibration":
521
+ typer.echo(f"{k:<32} {v}")
522
+ typer.echo("calibration (all blocks per shipped risk bin):")
523
+ for b in s["calibration"]:
524
+ typer.echo(f" risk {b['risk']}: table {b['table_rate']}, observed {b['observed_rate']} "
525
+ f"({b['regretted']}/{b['blocks']})")
526
+ typer.echo("Note: regret labels are a lower bound, so precision is conservative.")
527
+
528
+
529
+ @app.command("comment")
530
+ def comment_cmd(
531
+ report: Annotated[Path, typer.Option(help="Markdown from `fence check --format markdown`.")],
532
+ pr: Annotated[int, typer.Option(help="Pull request number.")],
533
+ repo_slug: Annotated[str | None, typer.Option(
534
+ "--repo-slug", help="owner/name (default: $GITHUB_REPOSITORY).")] = None,
535
+ ) -> None:
536
+ """Post or update the single sticky Fence comment on a PR (for CI)."""
537
+ import os
538
+
539
+ from fence.github.comment import upsert
540
+
541
+ token = os.environ.get("GITHUB_TOKEN")
542
+ slug = repo_slug or os.environ.get("GITHUB_REPOSITORY")
543
+ if not token or not slug:
544
+ raise _fail("GITHUB_TOKEN and GITHUB_REPOSITORY (or --repo-slug) are required")
545
+ api = os.environ.get("GITHUB_API_URL", "https://api.github.com")
546
+ result = upsert(api, slug, pr, report.read_text(encoding="utf-8"), token)
547
+ if result == "forbidden":
548
+ typer.echo("fence: token cannot comment (fork PR?); see the job summary instead",
549
+ err=True)
550
+ else:
551
+ typer.echo(f"fence: PR comment {result}")
552
+
553
+
554
+ @app.command("report")
555
+ def report_cmd(
556
+ path: Annotated[str, typer.Argument(help="Directory (default: repo root).")] = ".",
557
+ fmt: Annotated[str, typer.Option("--format", help="markdown | json | html")] = "markdown",
558
+ output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
559
+ repo: RepoOpt = Path("."),
560
+ ) -> None:
561
+ """Hotspot report: load-bearing, fragile-undocumented, churn and ownership."""
562
+ from fence import report
563
+
564
+ ctx = _open(repo)
565
+ _ensure_indexed(ctx, True)
566
+ try:
567
+ data = report.build(ctx, path)
568
+ except GitError as e:
569
+ raise _fail(str(e)) from None
570
+ text = (dumps(data) if fmt == "json" else report.to_html(data) if fmt == "html"
571
+ else report.to_markdown(data))
572
+ if output:
573
+ output.write_text(text, encoding="utf-8")
574
+ typer.echo(f"wrote {output}")
575
+ else:
576
+ typer.echo(text)