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
@@ -0,0 +1,579 @@
1
+ """Lines of code -> Decision Records, from blame plus the index. Deterministic.
2
+
3
+ One Decision Record is one introducing commit's change to one file (identified by
4
+ the file's path at that commit, so the id survives later renames). Its anchors are
5
+ the current line ranges that commit still owns.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field, replace
14
+
15
+ from fence.anchors.fingerprint import containment, shingles
16
+ from fence.check.diff import DiffError, parse_diff
17
+ from fence.context import Context
18
+ from fence.decisions import obsolete, reasons, relevance
19
+ from fence.decisions.model import Anchor, DecisionRecord, Evidence
20
+ from fence.git.blame import BlameLine, blame
21
+ from fence.git.log import log_line_range
22
+ from fence.git.runner import GitError
23
+ from fence.globs import match_any
24
+ from fence.index.store import CommitRow
25
+ from fence.lang import MODULE, enclosing_symbol, plugin_for, tokens_for
26
+ from fence.scoring.strength import StrengthInputs, strength
27
+ from fence.signals.catalog import (
28
+ DECISION_TYPES,
29
+ LABEL_SIGNALS,
30
+ TEXT_SIGNALS,
31
+ extract_labels,
32
+ extract_text,
33
+ )
34
+ from fence.signals.code import CODE_SIGNALS, match_code
35
+ from fence.text import clean, quoted
36
+
37
+ TEST_PATH = re.compile(
38
+ r"(^|/)(tests?|__tests__|spec|specs)/|_test\.(go|py)$|(^|/)test_[^/]+\.py$|"
39
+ r"\.(test|spec)\.[cm]?[jt]sx?$|Tests?\.java$"
40
+ )
41
+ VENDORED = ("vendor/**", "third_party/**", "node_modules/**", "**/generated/**", "**/*.pb.go",
42
+ "**/*_pb2.py", "**/*.min.js")
43
+ LINK_SIGNALS = {"text.closes_issue", "text.issue_ref", "text.ticket", "text.incident_url"}
44
+ FIX_SIGNALS = {"text.fix", "text.regression", "text.hotfix"}
45
+ _TEXT_TYPE = {s.id: s.decision_type for s in TEXT_SIGNALS + LABEL_SIGNALS}
46
+ _CODE_WEIGHT = {s.id: s.weight for s in CODE_SIGNALS}
47
+ _CODE_TYPE = {s.id: s.decision_type for s in CODE_SIGNALS}
48
+ MAX_LATER = 20
49
+
50
+
51
+ class WhyError(Exception):
52
+ """The target cannot be resolved (bad path, line out of range, unknown symbol)."""
53
+
54
+
55
+ @dataclass
56
+ class Target:
57
+ path: str
58
+ start: int
59
+ end: int
60
+ symbol: str | None
61
+ rev: str
62
+
63
+
64
+ @dataclass
65
+ class WhyResult:
66
+ target: Target
67
+ decisions: list[DecisionRecord]
68
+ notes: list[str] = field(default_factory=list)
69
+ unexplained_lines: int = 0
70
+
71
+
72
+ def resolve_target(
73
+ ctx: Context, path: str, start: int | None = None, end: int | None = None, *,
74
+ symbol: str | None = None, rev: str = "HEAD",
75
+ ) -> tuple[Target, str]:
76
+ rev_sha = ctx.repo.verify_ref(rev)
77
+ rel = ctx.repo.safe_relpath(path)
78
+ try:
79
+ text = ctx.repo.read_blob(rev_sha, rel, max_bytes=ctx.cfg.index.max_file_size)
80
+ except GitError as e:
81
+ raise WhyError(f"cannot read {rel} at {rev}: {e}") from None
82
+ if "\0" in text:
83
+ raise WhyError(f"{rel} is a binary file; Fence explains text lines only")
84
+ total = len(text.splitlines())
85
+ if total == 0:
86
+ raise WhyError(f"{rel} is empty at {rev}")
87
+ if symbol:
88
+ plugin = plugin_for(rel, ctx.cfg.index.languages)
89
+ syms = plugin.symbols(text) if plugin else None
90
+ if syms is None:
91
+ raise WhyError(f"no symbols available for {rel} (unsupported language or parse error)")
92
+ hits = [s for s in syms if s.qualname == symbol or s.qualname.endswith("." + symbol)]
93
+ if not hits:
94
+ raise WhyError(f"symbol {symbol!r} not found in {rel}")
95
+ s = hits[0]
96
+ return Target(rel, s.start_line, s.end_line, s.qualname, rev_sha), text
97
+ if start is None:
98
+ start, end = 1, total # whole file
99
+ elif end is None:
100
+ end = start
101
+ if not (1 <= start <= end <= total):
102
+ raise WhyError(f"line range {start}-{end} is outside {rel} (1-{total})")
103
+ sym = enclosing_symbol(rel, text, start)
104
+ return Target(rel, start, end, sym.qualname if sym else None, rev_sha), text
105
+
106
+
107
+ def format_only_shas(ctx: Context, paths: list[str]) -> list[str]:
108
+ marks = ",".join("?" * len(paths))
109
+ rows = ctx.store.conn.execute(
110
+ "SELECT DISTINCT c.sha FROM commits c JOIN file_changes f ON f.sha = c.sha " # noqa: S608
111
+ f"WHERE c.format_only = 1 AND f.path IN ({marks})", paths,
112
+ )
113
+ return [r["sha"] for r in rows]
114
+
115
+
116
+ def why(
117
+ ctx: Context, path: str, start: int | None = None, end: int | None = None, *,
118
+ symbol: str | None = None, rev: str = "HEAD", deep: bool = True,
119
+ ) -> WhyResult:
120
+ target, text = resolve_target(ctx, path, start, end, symbol=symbol, rev=rev)
121
+ lines = text.split("\n")
122
+ history = ctx.store.path_history(target.path)
123
+ blamed = blame(
124
+ ctx.repo, target.rev, target.path, [(target.start, target.end)],
125
+ extra_ignore=format_only_shas(ctx, history),
126
+ )
127
+ result = WhyResult(target, [])
128
+ if ctx.shallow:
129
+ result.notes.append("shallow clone: lines older than the boundary cannot be explained")
130
+ if ctx.offline:
131
+ result.notes.append("local-git mode: PR, issue and review text not available "
132
+ "(set GITHUB_TOKEN and run `fence index`)")
133
+ result.decisions = decisions_for_lines(ctx, target, lines, blamed, history, deep=deep)
134
+ result.unexplained_lines = sum(
135
+ len(range(a.start_line, a.end_line + 1))
136
+ for d in result.decisions if d.grade == "D" for a in d.anchors
137
+ )
138
+ return result
139
+
140
+
141
+ def decisions_for_lines(
142
+ ctx: Context, target: Target, lines: list[str], blamed: list[BlameLine],
143
+ history: list[str], *, deep: bool = True,
144
+ ) -> list[DecisionRecord]:
145
+ groups: dict[tuple[str, str], list[BlameLine]] = {}
146
+ for bl in blamed:
147
+ groups.setdefault((bl.sha, bl.filename), []).append(bl)
148
+ for _ in range(3): # follow moves at most three hops back
149
+ moved = False
150
+ for key in list(groups):
151
+ followed = _follow_move(ctx, key[0], key[1], groups[key])
152
+ if followed:
153
+ del groups[key]
154
+ for bl in followed:
155
+ groups.setdefault((bl.sha, bl.filename), []).append(bl)
156
+ moved = True
157
+ if not moved:
158
+ break
159
+ rows = ctx.store.commits(sha for sha, _ in groups)
160
+ records = [
161
+ _record(ctx, target, lines, sha, filename, bls, rows.get(sha), history, deep=deep)
162
+ for (sha, filename), bls in groups.items()
163
+ ]
164
+ records.sort(key=lambda r: (-r.strength.value, -r.introduced_at, r.id))
165
+ return records
166
+
167
+
168
+ def _follow_move(ctx: Context, sha: str, filename: str,
169
+ bls: list[BlameLine]) -> list[BlameLine] | None:
170
+ """If `sha` moved these lines here from another file (which git blame -C can miss),
171
+ return blame lines from that file at `sha`'s parent, keeping current line numbers."""
172
+ row = ctx.store.commit(sha)
173
+ if row is None or len(row.parents) != 1:
174
+ return None
175
+ sources = [c.path if c.status != "R" else c.old_path for c in ctx.store.changes_for(sha)
176
+ if (c.old_path or c.path) != filename and (c.deleted or 0) > 0]
177
+ sources = [s for s in sources if s][:20]
178
+ group_tokens = tokens_for(filename, "\n".join(bl.content for bl in bls))
179
+ exact = {bl.content.strip() for bl in bls}
180
+ distinctive = all(len(t) >= 15 for t in exact)
181
+ if not sources or (len(group_tokens) < 8 and not distinctive):
182
+ return None
183
+ digest = hashlib.sha256(chr(0).join(group_tokens).encode()).hexdigest()[:12]
184
+ key = f"move:{sha}:{filename}:{digest}"
185
+ if (cached := ctx.store.cache_get(key)) is not None and not cached:
186
+ return None
187
+ try:
188
+ text = ctx.repo.run(["show", "--format=", "-U0", "--no-color", "--no-ext-diff",
189
+ "--src-prefix=a/", "--dst-prefix=b/", sha, "--", *sources])
190
+ files = parse_diff(text)
191
+ except (GitError, DiffError):
192
+ return None
193
+ group_sh = shingles(group_tokens)
194
+ for f in files:
195
+ if f.old_path is None:
196
+ continue
197
+ for b in f.blocks:
198
+ removed_text = "\n".join(t for _, t in b.removed)
199
+ same = exact <= {t.strip() for _, t in b.removed}
200
+ if not (same and distinctive) and containment(
201
+ group_sh, shingles(tokens_for(f.old_path, removed_text))) < 0.8:
202
+ continue
203
+ by_text: dict[str, int] = {}
204
+ for n, t in b.removed:
205
+ by_text.setdefault(t.strip(), n)
206
+ src_lines = {bl.final_line: by_text.get(bl.content.strip()) for bl in bls}
207
+ wanted = sorted({n for n in src_lines.values() if n})
208
+ if not wanted:
209
+ continue
210
+ try:
211
+ older = blame(ctx.repo, row.parents[0], f.old_path,
212
+ [(n, n) for n in wanted][:200])
213
+ except GitError:
214
+ return None
215
+ by_line = {bl.final_line: bl for bl in older}
216
+ out = []
217
+ for bl in bls:
218
+ src = src_lines.get(bl.final_line)
219
+ out.append(replace(by_line[src], final_line=bl.final_line, content=bl.content)
220
+ if src in by_line else bl)
221
+ return out
222
+ with ctx.store.transaction():
223
+ ctx.store.cache_put(key, 0)
224
+ return None
225
+
226
+
227
+ def decision_id(sha: str, path: str) -> str:
228
+ return "D" + hashlib.sha256(f"{sha}\x00{path}".encode()).hexdigest()[:10]
229
+
230
+
231
+ def _ranges(nums: list[int]) -> list[tuple[int, int]]:
232
+ out: list[tuple[int, int]] = []
233
+ for n in sorted(set(nums)):
234
+ if out and n == out[-1][1] + 1:
235
+ out[-1] = (out[-1][0], n)
236
+ else:
237
+ out.append((n, n))
238
+ return out
239
+
240
+
241
+ def _record(
242
+ ctx: Context, target: Target, lines: list[str], sha: str, filename: str,
243
+ bls: list[BlameLine], row: CommitRow | None, history: list[str], *, deep: bool,
244
+ ) -> DecisionRecord:
245
+ first = bls[0]
246
+ message = row.message if row else first.summary
247
+ ev: list[Evidence] = []
248
+ authors = {first.author}
249
+
250
+ # The code this record covers, and which change text is evidence for it.
251
+ owned = [bl.final_line for bl in bls]
252
+ spans = _ranges(owned)
253
+ code_lines = [lines[n - 1] for n in owned if n - 1 < len(lines)]
254
+ plugin = plugin_for(target.path)
255
+ syms = (plugin.symbols("\n".join(lines)) if plugin else None) or []
256
+ span_syms = [s.qualname for s in syms
257
+ if any(s.start_line <= b and s.end_line >= a for a, b in spans)]
258
+ changes = ctx.store.changes_for(sha) if row else []
259
+ scope = relevance.build_scope(
260
+ filename, [(c.path, (c.added or 0) + (c.deleted or 0)) for c in changes] if row
261
+ else [(filename, 1), ("<unknown>", 1)],
262
+ code_lines, span_syms, test_pattern=TEST_PATH,
263
+ low_signal=ctx.cfg.scoring.low_signal_paths)
264
+
265
+ def add(**kw) -> Evidence:
266
+ e = Evidence(id=f"E{len(ev) + 1}", **kw)
267
+ ev.append(e)
268
+ return e
269
+
270
+ commit_ev = add(
271
+ type="commit", ref=sha, url=ctx.commit_url(sha), timestamp=first.author_time,
272
+ author=clean(first.author, max_len=80),
273
+ quote=clean(message, max_len=600, keep_newlines=True),
274
+ retrieval_status=("orphaned" if row and row.orphaned else "ok" if row else "not_indexed"),
275
+ )
276
+ signals = (ctx.store.signals_for(sha) if row
277
+ else extract_text(message))
278
+ sig_ids = {s for s, _ in signals} # whole change: evidence links shown to the reader
279
+ rel_message = relevance.relevant_text(message, scope)
280
+ # Signals from text that is about this code; they decide type, grade C and strength.
281
+ rel_signals = signals if scope.focused else extract_text(rel_message)
282
+ rel_ids = {s for s, _ in rel_signals}
283
+
284
+ stated: str | None = None
285
+ stated_ev: str | None = None
286
+ grade = "D"
287
+ constraints: list[str] = []
288
+ alternatives: list[str] = []
289
+
290
+ # PR, issues, reviews (grade A when they state a reason).
291
+ pr = row.pr_number if row else None
292
+ if pr is None and deep and not first.boundary:
293
+ pr = _find_merge_pr(ctx, sha, target.rev)
294
+ if pr is not None:
295
+ prow = ctx.store.conn.execute("SELECT * FROM prs WHERE number = ?", (pr,)).fetchone()
296
+ if prow and prow["state"] != "unreachable":
297
+ body = f"{prow['title'] or ''}\n\n{prow['body'] or ''}"
298
+ pe = add(type="pr", ref=f"#{pr}", url=prow["url"] or ctx.pr_url(pr), timestamp=None,
299
+ author=prow["author"], quote=clean(prow["title"] or "", max_len=200))
300
+ pr_sigs = extract_text(body, skip_refs=frozenset({f"#{pr}"}))
301
+ sig_ids |= {s for s, _ in pr_sigs}
302
+ signals = [*signals, *(x for x in pr_sigs if x[0] in LINK_SIGNALS and x not in signals)]
303
+ rel_body = relevance.relevant_text(prow["body"] or "", scope)
304
+ rel_pr = extract_text(relevance.relevant_text(body, scope),
305
+ skip_refs=frozenset({f"#{pr}"}))
306
+ rel_signals = [*rel_signals, *(x for x in rel_pr if x not in rel_signals)]
307
+ rel_ids |= {s for s, _ in rel_pr}
308
+ if scope.focused: # labels describe the whole PR
309
+ rel_ids |= set(extract_labels(json.loads(prow["labels"] or "[]")))
310
+ for reviewer in json.loads(prow["reviewers"] or "[]"):
311
+ authors.add(reviewer)
312
+ if (q := reasons.stated_reason(rel_body)) and not stated:
313
+ stated, stated_ev, grade = q, pe.id, "A"
314
+ constraints += reasons.constraints(rel_body)
315
+ alternatives += reasons.alternatives(rel_body)
316
+ for rc in ctx.store.conn.execute(
317
+ "SELECT * FROM review_comments WHERE pr_number = ? ORDER BY id", (pr,)
318
+ ):
319
+ # A line comment on this file is about this code; a review summary is
320
+ # PR-level text and gets the same scoping as the PR body.
321
+ on_file = rc["path"] in (filename, target.path)
322
+ text = (rc["body"] or "") if on_file else relevance.relevant_text(
323
+ rc["body"] or "", scope)
324
+ q = reasons.stated_reason(text)
325
+ if q:
326
+ re_ = add(type="review_comment", ref=f"#{pr}", url=rc["url"], timestamp=None,
327
+ author=rc["author"], quote=q)
328
+ if not stated:
329
+ stated, stated_ev, grade = q, re_.id, "A"
330
+ else:
331
+ add(type="pr", ref=f"#{pr}", url=ctx.pr_url(pr), timestamp=None, author=None,
332
+ quote=None, retrieval_status="unreachable" if prow else "not_fetched")
333
+
334
+ links: list[str] = []
335
+ for sid, detail in signals:
336
+ if sid not in LINK_SIGNALS or detail in links:
337
+ continue
338
+ links.append(detail)
339
+ if sid in ("text.closes_issue", "text.issue_ref") and detail.startswith("#"):
340
+ n = int(detail[1:])
341
+ irow = ctx.store.conn.execute("SELECT * FROM issues WHERE number = ?", (n,)).fetchone()
342
+ if irow and irow["retrieval_status"] == "ok":
343
+ ie = add(type="issue", ref=detail, url=irow["url"], timestamp=None, author=None,
344
+ quote=clean(irow["title"] or "", max_len=200))
345
+ sig_ids |= {s for s, _ in extract_text(f"{irow['title']}\n{irow['body']}")}
346
+ rel_issue = relevance.relevant_text(f"{irow['title']}\n\n{irow['body']}", scope)
347
+ rel_ids |= {s for s, _ in extract_text(rel_issue)}
348
+ q = reasons.stated_reason(relevance.relevant_text(irow["body"] or "", scope))
349
+ if q and not stated and sid == "text.closes_issue":
350
+ stated, stated_ev, grade = q, ie.id, "A"
351
+ else:
352
+ add(type="issue", ref=detail, url=ctx.issue_url(n), timestamp=None, author=None,
353
+ quote=None, retrieval_status=(irow["retrieval_status"] if irow
354
+ else "not_fetched"))
355
+ elif sid == "text.ticket":
356
+ add(type="ticket", ref=detail, url=None, timestamp=None, author=None, quote=None,
357
+ retrieval_status="not_fetched")
358
+ elif sid == "text.incident_url":
359
+ add(type="incident_link", ref=detail, url=detail, timestamp=None, author=None,
360
+ quote=None, retrieval_status="not_fetched")
361
+ if pr is not None:
362
+ links.append(f"PR #{pr}")
363
+
364
+ # Commit message (grade B), only the part that is about this code.
365
+ if not stated and (q := reasons.stated_reason(rel_message)):
366
+ stated, stated_ev, grade = q, commit_ev.id, "B"
367
+ # A reason the change states for itself, not shown to be about this code.
368
+ # A shallow clone's boundary commit only marks where history stops: it did not add
369
+ # these lines, so its message is no evidence for them.
370
+ cutoff = ctx.shallow and first.boundary
371
+ change_reason = None if scope.focused or cutoff else reasons.stated_reason(
372
+ relevance.change_level_text(message, scope))
373
+ constraints += reasons.constraints(rel_message)
374
+ alternatives += reasons.alternatives(rel_message)
375
+
376
+ # Code: the owned lines plus comment lines directly above the first one.
377
+ above = []
378
+ n = spans[0][0] - 1
379
+ while n >= 1 and len(above) < 4 and reasons.comment_text(lines[n - 1]) is not None:
380
+ above.insert(0, (n, lines[n - 1]))
381
+ n -= 1
382
+ code_sigs = match_code(code_lines + [t for _, t in above])
383
+ for ln, t in above + [(bl.final_line, bl.content) for bl in bls]:
384
+ c = reasons.comment_text(t)
385
+ if not c or reasons.is_injection(c):
386
+ continue
387
+ if not stated and (q := reasons.stated_reason(c)):
388
+ ce = add(type="code_comment", ref=f"{target.path}:{ln}@{target.rev[:12]}", url=None,
389
+ timestamp=None, author=None, quote=q)
390
+ stated, stated_ev, grade = q, ce.id, "B"
391
+ constraints += reasons.constraints(c, limit=1)
392
+
393
+ # Reverts.
394
+ reverted = ctx.store.reverted_by(sha)
395
+ reverters = ctx.store.reverts_of(sha, as_of=ctx.as_of)
396
+ if reverted:
397
+ add(type="revert", ref=reverted, url=ctx.commit_url(reverted), timestamp=None,
398
+ author=None, quote="this commit reverts " + reverted[:12])
399
+ for r in reverters:
400
+ add(type="revert", ref=r, url=ctx.commit_url(r), timestamp=None, author=None,
401
+ quote="reverted by " + r[:12] + " and later restored")
402
+
403
+ # Tests added alongside.
404
+ test_paths = relevance.related_tests(
405
+ [c.path for c in changes if TEST_PATH.search(c.path)], filename, scope)
406
+ for tp in test_paths[:3]:
407
+ add(type="test", ref=f"{tp}@{sha[:12]}", url=None, timestamp=None, author=None,
408
+ quote=clean(tp, max_len=200))
409
+
410
+ # Line history: later touches, regression chain, authors.
411
+ later: list[str] = []
412
+ regression = 0
413
+ if deep:
414
+ try:
415
+ touches = log_line_range(ctx.repo, target.rev, target.path, spans[0][0],
416
+ spans[-1][1], max_count=MAX_LATER)
417
+ except GitError:
418
+ touches = []
419
+ touch_rows = ctx.store.commits(t.sha for t in touches)
420
+ for t in touches:
421
+ if t.sha == sha:
422
+ continue
423
+ authors.add(t.author)
424
+ tr = touch_rows.get(t.sha)
425
+ if tr and tr.format_only:
426
+ continue
427
+ tsig_rows = ctx.store.signals_for(t.sha) if tr else extract_text(t.subject)
428
+ tsigs = {s for s, _ in tsig_rows}
429
+ if tsigs & FIX_SIGNALS:
430
+ regression += 1
431
+ if tr and tr.author_time > first.author_time:
432
+ later.append(t.sha)
433
+
434
+ # Type and grade C.
435
+ dtype = _decision_type(rel_ids, code_sigs, reverted is not None)
436
+ inferred = None
437
+ # Grade C needs a history signal (message, label, revert) about this code. The
438
+ # code's shape alone (it has a lock) says what the code does, not why it was added.
439
+ history_types = {_TEXT_TYPE[s] for s in rel_ids if _TEXT_TYPE.get(s)}
440
+ if reverted:
441
+ history_types.add("revert")
442
+ if grade == "D" and change_reason:
443
+ grade = "C"
444
+ inferred = (f"Inferred: added in a change to {scope.source_files} source files whose "
445
+ f"message says {quoted(change_reason)} No part of it names this code, so it "
446
+ "may not be the reason these lines exist.")
447
+ elif grade == "D" and dtype in history_types and dtype not in ("unknown", "cleanup"):
448
+ grade = "C"
449
+ cues = [d for s, d in rel_signals if _TEXT_TYPE.get(s) == dtype][:2]
450
+ cue = f" ({', '.join(repr(clean(c, max_len=40)) for c in cues)})" if cues else ""
451
+ inferred = f"Inferred: looks like a {dtype} change{cue}; no reason is stated."
452
+ elif grade == "D" and reverters:
453
+ # A test alongside is a strength factor, not a reason: it no longer lifts D to C
454
+ # (backtest: it graded most of a well-tested repo C without predicting anything).
455
+ grade = "C"
456
+ inferred = "Inferred: was reverted and then restored; no reason is stated."
457
+
458
+ later_count = _later_commits_to_file(ctx, history, row)
459
+ # Links count toward strength only when the text naming them is about this code.
460
+ rel_details = {d for s, d in rel_signals if s in LINK_SIGNALS}
461
+ rel_links = [x for x in links if x in rel_details or (x.startswith("PR #") and scope.focused)]
462
+ inputs = StrengthInputs(
463
+ decision_type=dtype, grade=grade, links=rel_links,
464
+ positive_text_signals=sorted(s for s in rel_ids
465
+ if s.startswith(("text.", "label.")) and _weight_positive(s)),
466
+ code_signals=[(s, _CODE_WEIGHT[s]) for s in sorted({s for s, _ in code_sigs})],
467
+ age_days=max(0.0, (_now(ctx, target.rev) - first.author_time) / 86400),
468
+ later_commits_to_file=later_count, regression_fixes=regression,
469
+ revert_involved=bool(reverted or reverters), test_added=bool(test_paths),
470
+ distinct_authors=len(authors), bulk=bool(row and row.bulk),
471
+ vendored=match_any(target.path, VENDORED),
472
+ automated="text.automated" in rel_ids, refactor="text.refactor" in rel_ids,
473
+ shallow=ctx.shallow or first.boundary, not_indexed=row is None,
474
+ pr_unfetched=any(e.type == "pr" and e.retrieval_status == "not_fetched" for e in ev),
475
+ offline=ctx.offline,
476
+ )
477
+ score = strength(inputs, ctx.cfg)
478
+
479
+ anchors = []
480
+ for a, b in spans:
481
+ # Label a span with the innermost symbol that contains all of it; a span that
482
+ # crosses symbols belongs to the module, not to whichever symbol it starts in.
483
+ inside = [s for s in syms if s.start_line <= a and b <= s.end_line]
484
+ label = inside[-1].qualname if inside else MODULE
485
+ anchors.append(Anchor(target.path, a, b, label if plugin else None,
486
+ plugin.name if plugin else None, tuple(history)))
487
+ status = "orphaned" if row and row.orphaned else "active"
488
+ if deep and status == "active":
489
+ stale = obsolete.caller_findings(ctx.repo, target.rev, anchors[0].symbol, target.path)
490
+ stale += obsolete.flag_findings(ctx.repo, target.rev, code_lines, target.path)
491
+ if row and dtype in ("compatibility", "dependency-pin", "bugfix"):
492
+ stale += obsolete.dependency_findings(ctx.repo, sha, target.rev, message,
493
+ ctx.cache)
494
+ for s in stale:
495
+ add(type="obsolescence", ref=target.rev[:12], url=None, timestamp=None, author=None,
496
+ quote=f"possibly obsolete, verify: {s}")
497
+ if stale:
498
+ status = "stale-suspected"
499
+ return DecisionRecord(
500
+ id=decision_id(sha, filename), anchors=anchors, type=dtype, grade=grade, # type: ignore[arg-type]
501
+ stated_reason=stated, stated_reason_evidence=stated_ev, inferred_reason=inferred,
502
+ constraints=list(dict.fromkeys(constraints))[:3],
503
+ alternatives_considered=list(dict.fromkeys(alternatives))[:2], evidence=ev,
504
+ introduced_sha=sha, introduced_at=first.author_time, introduced_path=filename,
505
+ last_touched_at=first.author_time, authors=sorted(authors), status=status,
506
+ strength=score, signals=sorted(set(signals) | {(s, d) for s, d in code_sigs}),
507
+ later_touches=later, pr_number=pr,
508
+ reason_scope="code" if stated else ("change" if change_reason else None),
509
+ change_reason=change_reason,
510
+ supersedes=[reverted] if reverted else [],
511
+ )
512
+
513
+
514
+ def _weight_positive(sid: str) -> bool:
515
+ return any(s.id == sid and s.weight > 0 for s in TEXT_SIGNALS + LABEL_SIGNALS)
516
+
517
+
518
+ def _decision_type(sig_ids: set[str], code_sigs: list[tuple[str, str]], is_revert: bool) -> str:
519
+ if is_revert:
520
+ return "revert"
521
+ types = {_TEXT_TYPE[s] for s in sig_ids if _TEXT_TYPE.get(s)}
522
+ positive = types - {"cleanup"}
523
+ for t in DECISION_TYPES:
524
+ if t in positive:
525
+ return t
526
+ code_types = {_CODE_TYPE[s] for s, _ in code_sigs if _CODE_TYPE.get(s)}
527
+ for t in DECISION_TYPES:
528
+ if t in code_types and t != "deliberate-design":
529
+ return t
530
+ if "cleanup" in types:
531
+ return "cleanup"
532
+ if any(s == "code.warning_comment" for s, _ in code_sigs):
533
+ return "deliberate-design"
534
+ return "unknown"
535
+
536
+
537
+ def _find_merge_pr(ctx: Context, sha: str, rev: str) -> int | None:
538
+ key = f"merge-pr:{sha}:{rev}"
539
+ cached = ctx.store.cache_get(key)
540
+ if cached is not None:
541
+ return cached or None # 0 means "looked, none"
542
+ pr = None
543
+ try:
544
+ out = ctx.repo.run(["rev-list", "--ancestry-path", "--merges", f"{sha}..{rev}"])
545
+ merges = out.split()
546
+ if merges:
547
+ row = ctx.store.commit(merges[-1]) # the oldest merge on the path brought it in
548
+ pr = row.pr_number if row else None
549
+ except GitError:
550
+ pr = None
551
+ with ctx.store.transaction():
552
+ ctx.store.cache_put(key, pr or 0)
553
+ return pr
554
+
555
+
556
+ def _later_commits_to_file(ctx: Context, history: list[str], row: CommitRow | None) -> int:
557
+ if row is None:
558
+ return 0
559
+ marks = ",".join("?" * len(history))
560
+ seq = ctx.store.conn.execute("SELECT seq FROM commits WHERE sha = ?", (row.sha,)).fetchone()
561
+ if not seq:
562
+ return 0
563
+ r = ctx.store.conn.execute(
564
+ "SELECT COUNT(DISTINCT c.sha) AS n FROM commits c JOIN file_changes f ON f.sha = c.sha " # noqa: S608
565
+ f"WHERE f.path IN ({marks}) AND c.seq > ? AND c.orphaned = 0 AND c.format_only = 0"
566
+ " AND c.committer_time <= ?",
567
+ [*history, seq["seq"], ctx.as_of if ctx.as_of is not None else 2**62],
568
+ ).fetchone()
569
+ return int(r["n"])
570
+
571
+
572
+ def _now(ctx: Context, rev: str) -> int:
573
+ """'Now' is the revision's commit time, so results depend only on repo state."""
574
+ key = f"time:{rev}"
575
+ if (t := ctx.store.cache_get(key)) is not None:
576
+ return int(t)
577
+ t = int(ctx.repo.run(["show", "-s", "--format=%ct", rev]).strip())
578
+ ctx.store.cache_put(key, t) # autocommit: the connection is not in a transaction here
579
+ return t