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,198 @@
1
+ """Fence Strength (how load-bearing code looks) and Change Risk (how risky an edit is).
2
+
3
+ Both are sums of named factors. Each factor carries a plain-English reason, so
4
+ `fence explain` can show exactly how a number was reached. There is no hidden
5
+ term: value = clamp(sum(points), 0, 100).
6
+
7
+ Default weights are starting values; DECISIONS.md records changes made after
8
+ running the backtest. Any weight can be overridden in fence.toml under
9
+ [scoring.weights] using the keys in WEIGHTS.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from fence.config import Config
17
+ from fence.decisions.model import Factor, Score
18
+ from fence.scoring import calibration
19
+
20
+ WEIGHTS: dict[str, float] = {
21
+ # origin type (strongest type only)
22
+ "type.incident": 25, "type.security": 25, "type.data-integrity": 22,
23
+ "type.concurrency": 18, "type.revert": 14, "type.bugfix": 12, "type.compatibility": 10,
24
+ "type.dependency-pin": 10, "type.performance": 8, "type.deliberate-design": 10,
25
+ "type.cleanup": 0, "type.unknown": 0,
26
+ # evidence quality
27
+ "grade.A": 15, "grade.B": 10, "grade.C": 4, "grade.D": 0,
28
+ # links
29
+ "link.each": 5, "link.cap": 15,
30
+ "corroboration.each": 3, "corroboration.cap": 9,
31
+ # code
32
+ "code.cap": 12,
33
+ # history shape
34
+ "survival.per_year": 3, "survival.cap": 9, "survival.min_later_commits": 3,
35
+ "regression.each": 8, "regression.cap": 24,
36
+ "revert.involved": 14,
37
+ "test.added": 10,
38
+ "authors.each": 3, "authors.cap": 9,
39
+ # negatives
40
+ "neg.bulk": -10, "neg.vendored": -30, "neg.automated": -10, "neg.refactor": -6,
41
+ # change risk
42
+ "kind.delete": 1.0, "kind.loosen": 0.85, "kind.alter": 0.5, "kind.move": 0.3,
43
+ "risk.tests_removed": 10, "risk.linked_tests_touched": 5,
44
+ "risk.low_signal_share": 0.5, # keep this share of risk on low-signal paths
45
+ "type.inferred_share": 0.5, # type points kept when the type is only inferred (C/D)
46
+ }
47
+
48
+ BASE_CONFIDENCE = {"A": 0.85, "B": 0.75, "C": 0.5, "D": 0.3}
49
+
50
+
51
+ def weights(cfg: Config) -> dict[str, float]:
52
+ unknown = set(cfg.scoring.weights) - set(WEIGHTS)
53
+ if unknown:
54
+ raise ValueError(f"unknown scoring weight(s): {', '.join(sorted(unknown))}")
55
+ return {**WEIGHTS, **cfg.scoring.weights}
56
+
57
+
58
+ @dataclass
59
+ class StrengthInputs:
60
+ decision_type: str
61
+ grade: str
62
+ links: list[str] = field(default_factory=list) # distinct issue/PR/ticket/incident refs
63
+ positive_text_signals: list[str] = field(default_factory=list) # distinct signal ids
64
+ code_signals: list[tuple[str, float]] = field(default_factory=list) # (id, weight)
65
+ age_days: float = 0.0
66
+ later_commits_to_file: int = 0
67
+ regression_fixes: int = 0
68
+ revert_involved: bool = False
69
+ test_added: bool = False
70
+ distinct_authors: int = 1
71
+ bulk: bool = False
72
+ vendored: bool = False
73
+ automated: bool = False
74
+ refactor: bool = False
75
+ # coverage, for confidence
76
+ shallow: bool = False
77
+ not_indexed: bool = False
78
+ pr_unfetched: bool = False
79
+ offline: bool = False
80
+
81
+
82
+ def strength(inp: StrengthInputs, cfg: Config) -> Score:
83
+ w = weights(cfg)
84
+ f: list[Factor] = []
85
+
86
+ def add(fid: str, points: float, reason: str) -> None:
87
+ if points:
88
+ f.append(Factor(fid, round(points, 1), reason))
89
+
90
+ t = inp.decision_type
91
+ if inp.grade in ("A", "B"):
92
+ add(f"type.{t}", w.get(f"type.{t}", 0), f"introduced as a {t} change")
93
+ else:
94
+ # A type guessed from keywords predicted nothing in the backtest; count it half.
95
+ add(f"type.{t}", w.get(f"type.{t}", 0) * w["type.inferred_share"],
96
+ f"looks like a {t} change (inferred, counts {w['type.inferred_share']:g}x)")
97
+ add(f"grade.{inp.grade}", w[f"grade.{inp.grade}"], _grade_reason(inp.grade))
98
+ if inp.links:
99
+ n = len(inp.links)
100
+ add("link", min(w["link.cap"], n * w["link.each"]),
101
+ f"linked to {n} issue/PR/ticket reference(s): {', '.join(inp.links[:3])}")
102
+ extra = max(0, len(inp.positive_text_signals) - 1)
103
+ if extra:
104
+ add("corroboration", min(w["corroboration.cap"], extra * w["corroboration.each"]),
105
+ f"{extra + 1} independent text signals agree")
106
+ if inp.code_signals:
107
+ pts = min(w["code.cap"], sum(x for _, x in inp.code_signals))
108
+ kinds = {sid.split(".", 1)[1].replace("_", " ") for sid, _ in inp.code_signals}
109
+ names = ", ".join(sorted(kinds))
110
+ add("code", pts, f"the code itself contains: {names}")
111
+ if inp.later_commits_to_file >= w["survival.min_later_commits"] and inp.age_days >= 180:
112
+ years = inp.age_days / 365
113
+ add("survival", min(w["survival.cap"], years * w["survival.per_year"]),
114
+ f"survived {inp.later_commits_to_file} later commits to the file "
115
+ f"over {years:.1f} years")
116
+ if inp.regression_fixes:
117
+ add("regression", min(w["regression.cap"], inp.regression_fixes * w["regression.each"]),
118
+ f"these lines were fixed again {inp.regression_fixes} time(s) later")
119
+ if inp.revert_involved:
120
+ add("revert", w["revert.involved"], "involved in a revert (reverted, or re-applied)")
121
+ if inp.test_added:
122
+ add("test", w["test.added"], "a test was added in the same change")
123
+ if inp.distinct_authors > 1:
124
+ add("authors", min(w["authors.cap"], (inp.distinct_authors - 1) * w["authors.each"]),
125
+ f"{inp.distinct_authors} people have touched these lines")
126
+ if inp.bulk:
127
+ add("neg.bulk", w["neg.bulk"], "landed in a very large bulk commit")
128
+ if inp.vendored:
129
+ add("neg.vendored", w["neg.vendored"], "vendored or generated path")
130
+ if inp.automated:
131
+ add("neg.automated", w["neg.automated"], "an automated or dependency-bot change")
132
+ if inp.refactor:
133
+ add("neg.refactor", w["neg.refactor"], "described as a refactor or cleanup")
134
+
135
+ value = int(round(max(0.0, min(100.0, sum(x.points for x in f)))))
136
+ return Score(value, cfg.scoring.band(value), confidence(inp), tuple(f), _what_would_change(inp))
137
+
138
+
139
+ def confidence(inp: StrengthInputs) -> float:
140
+ c = BASE_CONFIDENCE[inp.grade]
141
+ if inp.shallow:
142
+ c -= 0.15
143
+ if inp.not_indexed:
144
+ c -= 0.2
145
+ if inp.pr_unfetched:
146
+ c -= 0.05
147
+ if inp.offline and inp.grade in ("C", "D"):
148
+ c -= 0.05
149
+ return round(max(0.05, min(0.95, c)), 2)
150
+
151
+
152
+ def change_risk(
153
+ s: Score, kind: str, cfg: Config, *, tests_removed: bool = False,
154
+ path_bonus: int = 0, path_bonus_reason: str = "", low_signal: bool = False,
155
+ ) -> Score:
156
+ w = weights(cfg)
157
+ mult = w[f"kind.{kind}"]
158
+ f = [Factor("strength", round(s.value * mult, 1),
159
+ f"Fence Strength {s.value} x {mult} for a '{kind}' change")]
160
+ if tests_removed:
161
+ f.append(Factor("risk.tests_removed", w["risk.tests_removed"],
162
+ "tests are removed in the same change"))
163
+ if path_bonus:
164
+ f.append(Factor("risk.path", path_bonus, path_bonus_reason or "sensitive path"))
165
+ if low_signal:
166
+ keep = w["risk.low_signal_share"]
167
+ cut = -round(sum(x.points for x in f) * (1 - keep), 1)
168
+ f.append(Factor("risk.low_signal_path", cut,
169
+ f"docs/config/generated path: risk scaled by {keep:g}"))
170
+ value = int(round(max(0.0, min(100.0, sum(x.points for x in f)))))
171
+ cal = calibration.lookup(value)
172
+ return Score(value, cfg.scoring.band(value), s.confidence, (*f, *s.factors),
173
+ s.what_would_change, round(cal.rate, 4) if cal else None,
174
+ cal.n if cal else None)
175
+
176
+
177
+ def _grade_reason(g: str) -> str:
178
+ return {
179
+ "A": "reason stated in a PR, issue or review linked to the change",
180
+ "B": "reason stated in the commit message or a code comment",
181
+ "C": "reason inferred from change shape and labels only",
182
+ "D": "no reason recoverable",
183
+ }[g]
184
+
185
+
186
+ def _what_would_change(inp: StrengthInputs) -> tuple[str, ...]:
187
+ out = []
188
+ if inp.grade in ("C", "D"):
189
+ out.append("state the reason in a code comment or the PR description (raises grade)")
190
+ if not inp.links:
191
+ out.append("link the issue or incident it fixes")
192
+ if not inp.test_added:
193
+ out.append("add a test that fails without this code")
194
+ if inp.not_indexed:
195
+ out.append("run `fence index` so history is complete")
196
+ if inp.pr_unfetched:
197
+ out.append("set GITHUB_TOKEN and re-run `fence index` to read PR and issue text")
198
+ return tuple(out[:3])
File without changes
@@ -0,0 +1,161 @@
1
+ """The signal catalog: every pattern Fence reads as evidence, each with an id and weight.
2
+
3
+ Weights are Fence Strength points. They are starting values, tuned by the backtest
4
+ (see DECISIONS.md), and overridable in fence.toml under [scoring.weights].
5
+ Every signal here has its own positive and negative unit test.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Literal
13
+
14
+ Kind = Literal["text", "label", "review", "code", "structural", "negative"]
15
+
16
+ # Order matters only for picking a decision type: the first matching type wins.
17
+ DECISION_TYPES = (
18
+ "incident",
19
+ "security",
20
+ "data-integrity",
21
+ "concurrency",
22
+ "revert",
23
+ "bugfix",
24
+ "compatibility",
25
+ "dependency-pin",
26
+ "performance",
27
+ "deliberate-design",
28
+ "cleanup",
29
+ "unknown",
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Signal:
35
+ id: str
36
+ kind: Kind
37
+ weight: float
38
+ description: str
39
+ decision_type: str | None = None
40
+ pattern: re.Pattern[str] | None = None
41
+
42
+
43
+ def _rx(p: str) -> re.Pattern[str]:
44
+ return re.compile(p, re.IGNORECASE)
45
+
46
+
47
+ TEXT_SIGNALS: tuple[Signal, ...] = (
48
+ Signal("text.incident", "text", 20, "mentions an outage, incident or postmortem", "incident",
49
+ _rx(r"\b(outage|incident|post-?mortem|sev[ -]?[0-4]|downtime|page[ds]? on-?call)\b")),
50
+ Signal("text.security", "text", 22, "mentions a vulnerability or security fix", "security",
51
+ _rx(r"\b(cve-\d{4}-\d{4,}|security|xss|csrf|ssrf|sql injection|injection|"
52
+ r"vulnerab\w*|exploit\w*|buffer overflow|auth(entication)? bypass|sanitiz\w*)\b")),
53
+ Signal("text.data_integrity", "text", 18, "mentions data loss, duplicates or idempotency",
54
+ "data-integrity",
55
+ _rx(r"\b(duplicate[sd]?|corrupt\w*|data loss|idempoten\w*|integrity|"
56
+ r"double[- ]charg\w*|lost (writes|updates|data))\b")),
57
+ Signal("text.concurrency", "text", 16, "mentions a race, deadlock or thread safety",
58
+ "concurrency",
59
+ _rx(r"\b(race condition|data race|races?|deadlock\w*|thread[- ]safe\w*|"
60
+ r"concurren\w*|mutex|atomic\w*)\b")),
61
+ Signal("text.hotfix", "text", 14, "marked as a hotfix", "incident", _rx(r"\bhot-?fix\w*\b")),
62
+ Signal("text.regression", "text", 12, "mentions a regression", "bugfix",
63
+ _rx(r"\bregress(ion|ions|ed)?\b")),
64
+ Signal("text.do_not_remove", "text", 18, "says the code must stay", "deliberate-design",
65
+ _rx(r"\b(do not remove|don'?t remove|do not delete|don'?t delete|must not be removed|"
66
+ r"needed because|required because|is necessary|load[- ]bearing)\b")),
67
+ Signal("text.fix", "text", 8, "describes a fix", "bugfix",
68
+ _rx(r"\b(fix|fixes|fixed|fixing|bug|bugs|bugfix|patch(ed)?|crash\w*|broken)\b")),
69
+ Signal("text.leak", "text", 10, "mentions a leak or out-of-memory", "bugfix",
70
+ _rx(r"\b(memory leak|leak\w*|oom|out of memory)\b")),
71
+ Signal("text.workaround", "text", 10, "describes a workaround", "compatibility",
72
+ _rx(r"\b(workaround|work around|temporary|temp fix|hack|kludge)\b")),
73
+ Signal("text.compat", "text", 8, "mentions compatibility or a backport", "compatibility",
74
+ _rx(r"\b(compat\w*|backport\w*|polyfill|shim)\b")),
75
+ Signal("text.dependency_pin", "text", 10, "pins or holds back a dependency", "dependency-pin",
76
+ _rx(r"\b(pin(ned|s)? (to|at)|hold back|downgrade\w*|lock (to|at) version)\b")),
77
+ Signal("text.performance", "text", 6, "mentions performance", "performance",
78
+ _rx(r"\b(performance|perf|latency|slow(er|ness)?|speed ?up|optimi[sz]\w*)\b")),
79
+ Signal("text.rollback", "text", 6, "mentions a rollback", "revert",
80
+ _rx(r"\b(roll ?back|rolled back)\b")),
81
+ Signal("text.closes_issue", "text", 12, "closes or fixes a linked issue", None,
82
+ _rx(r"\b(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s*:?\s+(?P<v>(?:[\w.-]+/[\w.-]+)?#\d+)")),
83
+ Signal("text.issue_ref", "text", 4, "references an issue or PR", None,
84
+ _rx(r"(?<![\w/&])(?P<v>(?:[\w.-]+/[\w.-]+)?#\d+)\b")),
85
+ Signal("text.ticket", "text", 8, "references a ticket", None,
86
+ re.compile(r"\b(?P<v>[A-Z][A-Z0-9]{1,9}-\d{1,7})\b")),
87
+ Signal("text.incident_url", "text", 12, "links an incident or status page", "incident",
88
+ _rx(r"(?P<v>https?://[^\s)>\]]*(?:status|incident|pagerduty|opsgenie|postmortem)"
89
+ r"[^\s)>\]]*)")),
90
+ # Negative signals: these touches say little about why code exists.
91
+ Signal("text.formatting", "negative", -15, "a formatting or lint change", "cleanup",
92
+ _rx(r"\b(reformat\w*|format(ted|ting)? (code|files|with)|formatter|lint(ing)?|"
93
+ r"prettier|black|gofmt|isort|whitespace|code style)\b")),
94
+ Signal("text.refactor", "negative", -6, "a refactor or cleanup", "cleanup",
95
+ _rx(r"\b(refactor\w*|clean ?up|cleaned up|tidy|tidied|rename[sd]?)\b")),
96
+ Signal("text.automated", "negative", -10, "an automated or generated change", "cleanup",
97
+ _rx(r"\b(auto-?generated|regenerat\w*|dependabot|renovate(bot)?|"
98
+ r"bump(ed|s)? [\w@/.-]+ from)\b")),
99
+ )
100
+
101
+ # PR/issue labels, matched against whole label names (case-insensitive).
102
+ LABEL_SIGNALS: tuple[Signal, ...] = (
103
+ Signal("label.security", "label", 22, "labelled security", "security",
104
+ _rx(r"^(security|vulnerability|cve)\b")),
105
+ Signal("label.incident", "label", 20, "labelled incident/hotfix/p0", "incident",
106
+ _rx(r"^(incident|hotfix|p0|sev[ -]?[0-2]|outage|critical)\b")),
107
+ Signal("label.regression", "label", 12, "labelled regression", "bugfix",
108
+ _rx(r"^regression\b")),
109
+ Signal("label.bug", "label", 8, "labelled bug", "bugfix",
110
+ _rx(r"^(type: ?)?(bug|defect|fix)\b")),
111
+ Signal("label.performance", "label", 6, "labelled performance", "performance",
112
+ _rx(r"^(perf|performance)\b")),
113
+ Signal("label.chore", "negative", -6, "labelled chore/refactor/dependencies", "cleanup",
114
+ _rx(r"^(chore|refactor|dependencies|deps|documentation|docs)\b")),
115
+ )
116
+
117
+
118
+ def extract_labels(labels: list[str]) -> list[str]:
119
+ """Signal ids for a list of label names, in catalog order."""
120
+ out = []
121
+ for s in LABEL_SIGNALS:
122
+ if any(s.pattern.search(str(lb).strip()) for lb in labels if s.pattern):
123
+ out.append(s.id)
124
+ return out
125
+
126
+
127
+ _BY_ID = {s.id: s for s in TEXT_SIGNALS + LABEL_SIGNALS}
128
+
129
+
130
+ def signal(signal_id: str) -> Signal:
131
+ return _BY_ID[signal_id]
132
+
133
+
134
+ def all_signals() -> tuple[Signal, ...]:
135
+ from fence.signals.code import CODE_SIGNALS, STRUCTURAL_SIGNALS
136
+
137
+ return TEXT_SIGNALS + CODE_SIGNALS + STRUCTURAL_SIGNALS
138
+
139
+
140
+ MAX_DETAIL = 120
141
+
142
+
143
+ def extract_text(text: str, *, skip_refs: frozenset[str] = frozenset()) -> list[tuple[str, str]]:
144
+ """(signal_id, verbatim matched text) for every text signal in `text`. Deterministic order."""
145
+ found: list[tuple[str, str]] = []
146
+ seen: set[tuple[str, str]] = set()
147
+ closing: set[str] = set()
148
+ for s in TEXT_SIGNALS:
149
+ assert s.pattern is not None # noqa: S101 - every text signal has one
150
+ for m in s.pattern.finditer(text):
151
+ # Signals with a value to cite name it (?P<v>...); others cite the whole match.
152
+ detail = (m.group("v") if "v" in s.pattern.groupindex else m.group(0))[:MAX_DETAIL]
153
+ if s.id == "text.closes_issue":
154
+ closing.add(detail.lower())
155
+ if s.id == "text.issue_ref" and (detail.lower() in closing or detail in skip_refs):
156
+ continue # already counted as a closing reference, or it is the PR itself
157
+ key = (s.id, detail)
158
+ if key not in seen:
159
+ seen.add(key)
160
+ found.append(key)
161
+ return found
fence/signals/code.py ADDED
@@ -0,0 +1,69 @@
1
+ """Signals read from code text and from history shape (as opposed to messages).
2
+
3
+ Code signals look at the lines a decision covers; structural signals look at how
4
+ those lines lived (survival, flip-flops, re-fixes). Both feed Fence Strength.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from fence.signals.catalog import Signal, _rx
12
+
13
+ CODE_SIGNALS: tuple[Signal, ...] = (
14
+ Signal("code.warning_comment", "code", 14, "a comment warns against changing it",
15
+ "deliberate-design",
16
+ _rx(r"(?:#|//|/\*|\*|--)\s*(?:warning|important|careful|do not|don'?t|never|must|"
17
+ r"note)\b[^\n]{0,160}")),
18
+ Signal("code.hack_comment", "code", 8, "a TODO/HACK/XXX/FIXME comment", "compatibility",
19
+ re.compile(r"(?:#|//|/\*|\*|--)\s*(?:TODO|HACK|XXX|FIXME|WORKAROUND)\b[^\n]{0,160}")),
20
+ Signal("code.guard", "code", 6, "a guard clause or validation check", None,
21
+ re.compile(r"^\s*(?:if\b.*(?:\bis None\b|\bnull\b|\bnil\b|==\s*None|!=\s*None|"
22
+ r"\bnot\b|!|<|>|len\(|\.length|\bundefined\b)|"
23
+ r"if\s+err\s*!=\s*nil|assert\b|require\(|precondition|"
24
+ r"raise\b|throw\b|return\s+err\b)")),
25
+ Signal("code.exception_handling", "code", 6, "error handling", None,
26
+ re.compile(r"^\s*(?:except\b|catch\s*\(|catch\b|finally\b|rescue\b|recover\(\))")),
27
+ Signal("code.lock", "code", 10, "a lock, mutex or atomic operation", "concurrency",
28
+ _rx(r"\b(lock\(|unlock\(|\.lock\b|mutex|rwlock|semaphore|synchronized|atomic\w*|"
29
+ r"threading\.\w*lock|with\s+[\w.]*lock\b|sync\.(Mutex|RWMutex|Once)|"
30
+ r"compareandset|\bcas\()")),
31
+ Signal("code.retry_timeout", "code", 8, "a retry, backoff, sleep or timeout", None,
32
+ _rx(r"\b(retr(y|ies|ying)|backoff|back_off|sleep\(|time\.sleep|settimeout|"
33
+ r"timeout|deadline|max_attempts|attempts?)\b")),
34
+ Signal("code.feature_flag", "code", 6, "behind a feature flag", None,
35
+ _rx(r"\b(feature[_ ]?flag|is_enabled\(|isenabled\(|flags?\.\w+|"
36
+ r"launchdarkly|unleash)\b")),
37
+ Signal("code.assertion", "code", 4, "an assertion or invariant", None,
38
+ _rx(r"^\s*(assert\b|assert\w*\(|invariant\(|check\w*\()")),
39
+ )
40
+
41
+ STRUCTURAL_SIGNALS: tuple[Signal, ...] = (
42
+ Signal("struct.survivor", "structural", 1, "survived later edits nearby (per year, capped)"),
43
+ Signal("struct.test_added", "structural", 10, "a test was added in the same change"),
44
+ Signal("struct.revert_involved", "structural", 14, "involved in a revert"),
45
+ Signal("struct.regression_chain", "structural", 8, "the same lines were re-fixed (per fix)"),
46
+ Signal("struct.flip_flop", "structural", 12, "removed and re-added before"),
47
+ Signal("struct.multi_author", "structural", 3, "touched or reviewed by several people (each)"),
48
+ Signal("struct.linked_pr", "structural", 6, "landed through a pull request"),
49
+ Signal("struct.constant_in_fix", "structural", 8, "a constant changed in a fix commit"),
50
+ Signal("struct.format_only", "negative", -20, "only formatting touched these lines"),
51
+ Signal("struct.bulk_change", "negative", -10, "landed in a very large bulk commit"),
52
+ Signal("struct.vendored", "negative", -30, "vendored or generated code"),
53
+ )
54
+
55
+
56
+ def match_code(lines: list[str]) -> list[tuple[str, str]]:
57
+ """(signal_id, verbatim line text) for code signals in `lines`."""
58
+ found: list[tuple[str, str]] = []
59
+ seen: set[tuple[str, str]] = set()
60
+ for line in lines:
61
+ for s in CODE_SIGNALS:
62
+ assert s.pattern is not None # noqa: S101
63
+ m = s.pattern.search(line)
64
+ if m:
65
+ key = (s.id, line.strip()[:160])
66
+ if key not in seen:
67
+ seen.add(key)
68
+ found.append(key)
69
+ return found
fence/text.py ADDED
@@ -0,0 +1,43 @@
1
+ """Make untrusted repository text safe to print in a terminal or PR comment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unicodedata
6
+
7
+ # Bidi controls can make code or commit text display differently from what it is
8
+ # ("Trojan Source"). Zero-width characters can hide text.
9
+ _INVISIBLE = {
10
+ *map(chr, range(0x202A, 0x202F)),
11
+ *map(chr, range(0x2066, 0x206A)),
12
+ "​",
13
+ "‌",
14
+ "‍",
15
+ "‎",
16
+ "‏",
17
+ "",
18
+ }
19
+
20
+
21
+ def clean(text: str, *, max_len: int = 500, keep_newlines: bool = False) -> str:
22
+ """Drop control and invisible characters, keep tabs as spaces, cap the length.
23
+
24
+ Escape sequences such as "\\x1b[2J" would otherwise be executed by the terminal.
25
+ """
26
+ out = []
27
+ for ch in text:
28
+ if ch == "\n" and keep_newlines:
29
+ out.append(ch)
30
+ elif ch == "\t":
31
+ out.append(" ")
32
+ elif ch in _INVISIBLE or unicodedata.category(ch) in ("Cc", "Cf", "Cs", "Co"):
33
+ out.append("�" if ch == "\x1b" else "")
34
+ else:
35
+ out.append(ch)
36
+ result = "".join(out)
37
+ return result if len(result) <= max_len else result[: max_len - 1] + "…"
38
+
39
+
40
+ def quoted(text: str) -> str:
41
+ """Wrap a verbatim quote in delimiters it does not contain, so a quote that starts
42
+ with "fence" does not print as ""fence" ...". Double quotes unless the text has one."""
43
+ return f"'{text}'" if '"' in text else f'"{text}"'
fence/trace.py ADDED
@@ -0,0 +1,65 @@
1
+ """Removed ranges -> the commits that introduced them, at an explicit base revision.
2
+
3
+ Plain pipeline code: no model calls, no guessing. If the diff does not match the
4
+ base, we stop with PreimageMismatch instead of blaming lines that might be different.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+
12
+ from fence.diffing.ranges import RemovedRange
13
+ from fence.git.blame import BlameLine, blame
14
+ from fence.git.runner import GitError, Repo
15
+ from fence.lang import Symbol, enclosing_symbol
16
+
17
+
18
+ class PreimageMismatch(GitError):
19
+ """The diff's removed lines are not what the base revision contains."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class TraceResult:
24
+ range: RemovedRange
25
+ symbol: Symbol | None
26
+ lines: tuple[BlameLine, ...]
27
+
28
+ @property
29
+ def introducing(self) -> tuple[BlameLine, ...]:
30
+ """One BlameLine per distinct commit, in line order."""
31
+ seen: dict[str, BlameLine] = {}
32
+ for bl in self.lines:
33
+ seen.setdefault(bl.sha, bl)
34
+ return tuple(seen.values())
35
+
36
+
37
+ def trace(
38
+ repo: Repo, base: str, ranges: Sequence[RemovedRange], *,
39
+ extra_ignore: Sequence[str] = (),
40
+ ) -> list[TraceResult]:
41
+ base_sha = repo.verify_ref(base)
42
+ by_path: dict[str, list[RemovedRange]] = {}
43
+ for r in ranges:
44
+ by_path.setdefault(r.path, []).append(r)
45
+
46
+ results: list[TraceResult] = []
47
+ for path, path_ranges in by_path.items():
48
+ text = repo.read_blob(base_sha, path)
49
+ base_lines = text.split("\n")
50
+ for r in path_ranges:
51
+ actual = base_lines[r.start - 1 : r.end]
52
+ if list(r.lines) != actual:
53
+ raise PreimageMismatch(
54
+ f"{path}:{r.start}-{r.end} in the diff does not match {base_sha[:12]}; "
55
+ "was the diff made against a different base?"
56
+ )
57
+ spans = [(r.start, r.end) for r in path_ranges]
58
+ blamed = {
59
+ bl.final_line: bl
60
+ for bl in blame(repo, base_sha, path, spans, extra_ignore=extra_ignore)
61
+ }
62
+ for r in path_ranges:
63
+ lines = tuple(blamed[n] for n in range(r.start, r.end + 1))
64
+ results.append(TraceResult(r, enclosing_symbol(path, text, r.start), lines))
65
+ return results