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