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/decisions/build.py
ADDED
|
@@ -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
|