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/model.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Decision Records and their evidence: the objects every interface returns.
|
|
2
|
+
|
|
3
|
+
JSON output is versioned (`SCHEMA`). A field is never renamed or retyped within a
|
|
4
|
+
major version; new optional fields may be added.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
SCHEMA = "fence/v1"
|
|
13
|
+
# Shown wherever Strength or Change Risk is output. The pooled backtest's lift
|
|
14
|
+
# interval includes 1 (README, "Measured results"), so the scores claim no accuracy.
|
|
15
|
+
SCORE_NOTE = ("Experimental: Strength and Change Risk are explained sums of evidence "
|
|
16
|
+
"factors, not probabilities. Backtests have not established that they "
|
|
17
|
+
"predict which removals are later regretted.")
|
|
18
|
+
|
|
19
|
+
EvidenceType = Literal[
|
|
20
|
+
"commit", "pr", "issue", "review_comment", "revert", "code_comment", "test", "ticket",
|
|
21
|
+
"incident_link", "obsolescence",
|
|
22
|
+
]
|
|
23
|
+
RetrievalStatus = Literal["ok", "not_fetched", "unreachable", "orphaned", "not_indexed"]
|
|
24
|
+
Status = Literal["active", "superseded", "reverted", "orphaned", "stale-suspected"]
|
|
25
|
+
Grade = Literal["A", "B", "C", "D"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Evidence:
|
|
30
|
+
id: str # E1, E2 ... unique within one response
|
|
31
|
+
type: EvidenceType
|
|
32
|
+
ref: str # SHA, "#123", "path:line@rev", or a URL
|
|
33
|
+
url: str | None
|
|
34
|
+
timestamp: int | None # unix seconds
|
|
35
|
+
author: str | None
|
|
36
|
+
quote: str | None # verbatim, cleaned for display, length-capped
|
|
37
|
+
retrieval_status: RetrievalStatus = "ok"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Anchor:
|
|
42
|
+
path: str
|
|
43
|
+
start_line: int
|
|
44
|
+
end_line: int
|
|
45
|
+
symbol: str | None
|
|
46
|
+
language: str | None
|
|
47
|
+
path_history: tuple[str, ...] = ()
|
|
48
|
+
fingerprint: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Factor:
|
|
53
|
+
id: str
|
|
54
|
+
points: float
|
|
55
|
+
reason: str # plain English
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Score:
|
|
60
|
+
value: int
|
|
61
|
+
band: str
|
|
62
|
+
confidence: float
|
|
63
|
+
factors: tuple[Factor, ...]
|
|
64
|
+
what_would_change: tuple[str, ...] = ()
|
|
65
|
+
# Change Risk only: regret rate from a shipped calibration table and its support.
|
|
66
|
+
# No table ships (the pooled backtest did not support one), so both stay None.
|
|
67
|
+
regret_rate: float | None = None
|
|
68
|
+
regret_n: int | None = None
|
|
69
|
+
|
|
70
|
+
def top(self, n: int = 3) -> tuple[Factor, ...]:
|
|
71
|
+
return tuple(sorted(self.factors, key=lambda f: (-abs(f.points), f.id))[:n])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class DecisionRecord:
|
|
76
|
+
id: str
|
|
77
|
+
anchors: list[Anchor]
|
|
78
|
+
type: str
|
|
79
|
+
grade: Grade
|
|
80
|
+
stated_reason: str | None # verbatim quote or None, never paraphrase
|
|
81
|
+
stated_reason_evidence: str | None # evidence id the quote comes from
|
|
82
|
+
inferred_reason: str | None # always prefixed "Inferred:"
|
|
83
|
+
constraints: list[str]
|
|
84
|
+
alternatives_considered: list[str]
|
|
85
|
+
evidence: list[Evidence]
|
|
86
|
+
introduced_sha: str
|
|
87
|
+
introduced_at: int
|
|
88
|
+
introduced_path: str
|
|
89
|
+
last_touched_at: int
|
|
90
|
+
authors: list[str]
|
|
91
|
+
status: Status
|
|
92
|
+
strength: Score
|
|
93
|
+
signals: list[tuple[str, str]] = field(default_factory=list)
|
|
94
|
+
later_touches: list[str] = field(default_factory=list) # SHAs, newest first
|
|
95
|
+
supersedes: list[str] = field(default_factory=list)
|
|
96
|
+
superseded_by: list[str] = field(default_factory=list)
|
|
97
|
+
pr_number: int | None = None
|
|
98
|
+
# Where the reason applies: "code" when the evidence is about these lines (a
|
|
99
|
+
# focused change, or text naming this code), "change" when only the change as a
|
|
100
|
+
# whole states it (then it is never stated_reason), None when there is no reason.
|
|
101
|
+
reason_scope: str | None = None
|
|
102
|
+
change_reason: str | None = None # verbatim, change-level; see reason_scope
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def reason_text(self) -> str:
|
|
106
|
+
if self.stated_reason:
|
|
107
|
+
return self.stated_reason
|
|
108
|
+
if self.inferred_reason:
|
|
109
|
+
return self.inferred_reason
|
|
110
|
+
return "Reason unknown: no stated or inferable reason found in history."
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict:
|
|
113
|
+
d = asdict(self)
|
|
114
|
+
d["strength"]["top_factors"] = [asdict(f) for f in self.strength.top()]
|
|
115
|
+
d["signals"] = [{"id": s, "detail": det} for s, det in self.signals]
|
|
116
|
+
d["reason"] = self.reason_text
|
|
117
|
+
return d
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Obsolescence (F7/D3): concrete evidence that a fence may no longer be needed.
|
|
2
|
+
|
|
3
|
+
Only concrete, checkable facts mark a decision stale-suspected:
|
|
4
|
+
- a dependency its commit names was removed from the manifests, or upgraded;
|
|
5
|
+
- a feature flag its lines read no longer appears anywhere else in the repo;
|
|
6
|
+
- the function it lives in has no remaining references.
|
|
7
|
+
The output is always "possibly obsolete, verify", never an instruction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import tomllib
|
|
15
|
+
|
|
16
|
+
from fence.git.runner import GitError, Repo
|
|
17
|
+
|
|
18
|
+
MANIFESTS = ("requirements.txt", "pyproject.toml", "package.json", "go.mod", "setup.py")
|
|
19
|
+
_REQ = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[=<>~!]=?\s*([\w.]+))?")
|
|
20
|
+
_GO = re.compile(r"^\s*(?:require\s+)?([\w./-]+\.[\w./-]+)\s+v([\w.+-]+)", re.M)
|
|
21
|
+
_FLAG = re.compile(r"(?:flags?\.(\w+)|is_?enabled\(\s*['\"]([\w.-]+)['\"])", re.I)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _version_key(v: str | None) -> tuple:
|
|
25
|
+
return tuple(int(x) if x.isdigit() else 0 for x in re.split(r"[.+-]", v or "")[:4])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def manifests(repo: Repo, rev: str) -> dict[str, str | None]:
|
|
29
|
+
"""{package: version or None} from the manifests at `rev` (root directory only)."""
|
|
30
|
+
deps: dict[str, str | None] = {}
|
|
31
|
+
for name in MANIFESTS:
|
|
32
|
+
try:
|
|
33
|
+
text = repo.read_blob(rev, name, max_bytes=500_000)
|
|
34
|
+
except GitError:
|
|
35
|
+
continue
|
|
36
|
+
if name == "package.json":
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(text)
|
|
39
|
+
except ValueError:
|
|
40
|
+
continue
|
|
41
|
+
for sec in ("dependencies", "devDependencies", "peerDependencies"):
|
|
42
|
+
for k, v in (data.get(sec) or {}).items():
|
|
43
|
+
deps[k.lower()] = re.sub(r"^[^\d]*", "", str(v)) or None
|
|
44
|
+
elif name == "go.mod":
|
|
45
|
+
for m in _GO.finditer(text):
|
|
46
|
+
deps[m.group(1).lower()] = m.group(2)
|
|
47
|
+
elif name == "pyproject.toml":
|
|
48
|
+
try:
|
|
49
|
+
data = tomllib.loads(text)
|
|
50
|
+
except tomllib.TOMLDecodeError:
|
|
51
|
+
continue
|
|
52
|
+
reqs = list((data.get("project") or {}).get("dependencies") or [])
|
|
53
|
+
for group in ((data.get("project") or {}).get("optional-dependencies") or {}).values():
|
|
54
|
+
reqs += list(group)
|
|
55
|
+
for r in reqs:
|
|
56
|
+
if m := _REQ.match(str(r)):
|
|
57
|
+
deps[m.group(1).lower()] = m.group(2)
|
|
58
|
+
else:
|
|
59
|
+
for line in text.splitlines():
|
|
60
|
+
if line.strip().startswith(("#", "-")):
|
|
61
|
+
continue
|
|
62
|
+
if m := _REQ.match(line):
|
|
63
|
+
deps[m.group(1).lower()] = m.group(2)
|
|
64
|
+
return deps
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dependency_findings(repo: Repo, intro_sha: str, rev: str, message: str,
|
|
68
|
+
cache: dict) -> list[str]:
|
|
69
|
+
then = cache.setdefault(("m", intro_sha), manifests(repo, intro_sha))
|
|
70
|
+
now = cache.setdefault(("m", rev), manifests(repo, rev))
|
|
71
|
+
out = []
|
|
72
|
+
text = message.lower()
|
|
73
|
+
for pkg, v_then in sorted(then.items()):
|
|
74
|
+
if len(pkg) < 3 or not re.search(rf"(?<![\w-]){re.escape(pkg)}(?![\w-])", text):
|
|
75
|
+
continue
|
|
76
|
+
if pkg not in now:
|
|
77
|
+
out.append(f"dependency '{pkg}' named in the commit has since been removed")
|
|
78
|
+
elif v_then and now[pkg] and _version_key(now[pkg]) > _version_key(v_then):
|
|
79
|
+
out.append(f"dependency '{pkg}' named in the commit was upgraded "
|
|
80
|
+
f"from {v_then} to {now[pkg]}")
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def flag_findings(repo: Repo, rev: str, lines: list[str], path: str) -> list[str]:
|
|
85
|
+
out = []
|
|
86
|
+
for line in lines:
|
|
87
|
+
for m in _FLAG.finditer(line):
|
|
88
|
+
flag = m.group(1) or m.group(2)
|
|
89
|
+
if not flag or len(flag) < 4:
|
|
90
|
+
continue
|
|
91
|
+
hits = _grep_count(repo, rev, flag)
|
|
92
|
+
if hits is not None and hits <= 1:
|
|
93
|
+
out.append(f"feature flag '{flag}' is not referenced anywhere else")
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def caller_findings(repo: Repo, rev: str, symbol: str | None, path: str = "") -> list[str]:
|
|
98
|
+
if not symbol or symbol == "<module>":
|
|
99
|
+
return []
|
|
100
|
+
name = symbol.rsplit(".", 1)[-1]
|
|
101
|
+
if len(name) < 4 or name.startswith("__") or name in ("main", "init", "setup", "test"):
|
|
102
|
+
return []
|
|
103
|
+
# Only private symbols: a public function may be called by code outside this repo.
|
|
104
|
+
private = (path.endswith((".py", ".pyi")) and name.startswith("_")) or (
|
|
105
|
+
path.endswith(".go") and name[0].islower())
|
|
106
|
+
if not private:
|
|
107
|
+
return []
|
|
108
|
+
hits = _grep_count(repo, rev, name)
|
|
109
|
+
if hits is not None and hits <= 1:
|
|
110
|
+
return [f"'{symbol}' has no remaining references in the repository"]
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _grep_count(repo: Repo, rev: str, word: str) -> int | None:
|
|
115
|
+
if not re.fullmatch(r"[\w.-]+", word):
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
out = repo.run(["grep", "-c", "-w", "-F", "-e", word, rev, "--"])
|
|
119
|
+
except GitError:
|
|
120
|
+
return 0 # git grep exits 1 when nothing matches
|
|
121
|
+
return sum(int(line.rsplit(":", 1)[1]) for line in out.splitlines() if ":" in line)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Find an explicitly stated reason in untrusted text, and quote it verbatim.
|
|
2
|
+
|
|
3
|
+
A "stated reason" is a sentence that explains causation ("because", "otherwise",
|
|
4
|
+
"produced duplicate charges"). Fence never paraphrases it: it returns the exact
|
|
5
|
+
sentence (cleaned of control characters) or nothing. Sentences that try to talk
|
|
6
|
+
to a tool ("ignore previous instructions") are dropped, never quoted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from fence.text import clean
|
|
14
|
+
|
|
15
|
+
MAX_QUOTE = 300
|
|
16
|
+
|
|
17
|
+
_REASON = re.compile(
|
|
18
|
+
r"\b(because|since|so that|otherwise|or else|to (prevent|avoid|stop|ensure|guard|protect)|"
|
|
19
|
+
r"prevents?|prevented|caus(e|es|ed|ing)|produc(es|ed|ing)|led to|leads to|"
|
|
20
|
+
r"result(s|ed)? in|in order to|due to|required (by|for)|needed (by|for)|"
|
|
21
|
+
r"without (this|it)|was (failing|crashing|breaking|timing out)|broke|breaks|"
|
|
22
|
+
# purpose clauses: "... so a typo cannot weaken policy"
|
|
23
|
+
r"so (that )?(\w+ ){0,5}(cannot|can't|won't|will not|never|doesn't|does not))\b",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
_TRAILER = re.compile(r"^[\w-]+(-by)?:\s", re.IGNORECASE)
|
|
27
|
+
_REVERT_LINE = re.compile(r"^This reverts commit [0-9a-f]+", re.IGNORECASE)
|
|
28
|
+
# Text aimed at an AI tool is untrusted input, not evidence.
|
|
29
|
+
_INJECTION = re.compile(
|
|
30
|
+
r"\b(ignore (all |any )?(previous|prior|above) (instructions|prompts?)|"
|
|
31
|
+
r"you are (an? )?(ai|assistant|language model)|system prompt|disregard .{0,20}instructions|"
|
|
32
|
+
r"as an ai)\b",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
# Reasons that carry no causal word, each narrow enough not to fire on description:
|
|
36
|
+
# - a result clause: "'now' is the commit time, so the same state scores the same."
|
|
37
|
+
_RESULT = re.compile(r",\s+so\s+(?!far\b|on\b|forth\b)\w", re.IGNORECASE)
|
|
38
|
+
# - a hazard: "an escape sequence would be executed by the reader's terminal."
|
|
39
|
+
_HAZARD = re.compile(
|
|
40
|
+
r"\b(would|could|might)( otherwise| then)?( (be|get))? (executed|lost|corrupted|leaked|"
|
|
41
|
+
r"exposed|dropped|duplicated|overwritten|truncated|deleted|injected|bypassed|"
|
|
42
|
+
r"crash(es)?|fail|hang|deadlock|corrupt|leak)\b",
|
|
43
|
+
re.IGNORECASE,
|
|
44
|
+
)
|
|
45
|
+
# - a consequence of the previous sentence: "... Fence would explain the wrong code. So
|
|
46
|
+
# every removed range is compared first." It is quoted together with that sentence.
|
|
47
|
+
_CONSEQUENCE = re.compile(
|
|
48
|
+
r"^(so(?! far\b)|hence|therefore|thus|that is why|this is why|that's why|"
|
|
49
|
+
r"for (this|that) reason)\b",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
_PARAGRAPH_SPLIT = re.compile(r"\n\s*\n|\n(?=\s*[-*] )")
|
|
53
|
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9`\"'(])")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def paragraphs(text: str) -> list[list[str]]:
|
|
57
|
+
"""Sentences grouped by paragraph or bullet item. Neighbours in one paragraph are
|
|
58
|
+
each other's context; sentences in different paragraphs are unrelated."""
|
|
59
|
+
out: list[list[str]] = []
|
|
60
|
+
for para in _PARAGRAPH_SPLIT.split(text or ""):
|
|
61
|
+
group = []
|
|
62
|
+
for block in _SENTENCE_SPLIT.split(para):
|
|
63
|
+
s = " ".join(block.split()) # join wrapped lines
|
|
64
|
+
s = s.lstrip("-* ").strip()
|
|
65
|
+
if s and not _TRAILER.match(s) and not _REVERT_LINE.match(s):
|
|
66
|
+
group.append(s)
|
|
67
|
+
if group:
|
|
68
|
+
out.append(group)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sentences(text: str) -> list[str]:
|
|
73
|
+
return [s for para in paragraphs(text) for s in para]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def states_reason(sentence: str) -> bool:
|
|
77
|
+
return bool(_REASON.search(sentence) or _RESULT.search(sentence)
|
|
78
|
+
or _HAZARD.search(sentence))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_injection(sentence: str) -> bool:
|
|
82
|
+
return bool(_INJECTION.search(sentence))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stated_reason(text: str) -> str | None:
|
|
86
|
+
"""The first sentence in `text` that states a reason, verbatim, or None.
|
|
87
|
+
|
|
88
|
+
Which text is about which code is decided before this call (see relevance.py);
|
|
89
|
+
this function only finds the reason sentence.
|
|
90
|
+
"""
|
|
91
|
+
for para in paragraphs(text):
|
|
92
|
+
for i, s in enumerate(para):
|
|
93
|
+
if is_injection(s):
|
|
94
|
+
continue
|
|
95
|
+
if states_reason(s):
|
|
96
|
+
return clean(s, max_len=MAX_QUOTE)
|
|
97
|
+
if i and _CONSEQUENCE.match(s) and not is_injection(para[i - 1]):
|
|
98
|
+
# Still verbatim: the two sentences are adjacent in the source.
|
|
99
|
+
return clean(f"{para[i - 1]} {s}", max_len=MAX_QUOTE)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_CONSTRAINT = re.compile(
|
|
104
|
+
r"\b(must( not)?|never|always|do not|don'?t|required|has to|needs? to|only (when|if))\b",
|
|
105
|
+
re.IGNORECASE,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def constraints(text: str, *, limit: int = 3) -> list[str]:
|
|
110
|
+
"""Sentences that state a constraint ("must not", "never"), verbatim."""
|
|
111
|
+
out: list[str] = []
|
|
112
|
+
for s in sentences(text):
|
|
113
|
+
if is_injection(s) or not _CONSTRAINT.search(s):
|
|
114
|
+
continue
|
|
115
|
+
q = clean(s, max_len=MAX_QUOTE)
|
|
116
|
+
if q not in out:
|
|
117
|
+
out.append(q)
|
|
118
|
+
if len(out) >= limit:
|
|
119
|
+
break
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_ALTERNATIVE = re.compile(
|
|
124
|
+
r"\b(instead of|rather than|alternatively|considered|we could have|an alternative)\b",
|
|
125
|
+
re.IGNORECASE,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def alternatives(text: str, *, limit: int = 2) -> list[str]:
|
|
130
|
+
out: list[str] = []
|
|
131
|
+
for s in sentences(text):
|
|
132
|
+
if not is_injection(s) and _ALTERNATIVE.search(s):
|
|
133
|
+
out.append(clean(s, max_len=MAX_QUOTE))
|
|
134
|
+
if len(out) >= limit:
|
|
135
|
+
break
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_COMMENT = re.compile(r"^\s*(#|//|/\*+|\*|--)\s?(.*?)\s*(\*/)?\s*$")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def comment_text(line: str) -> str | None:
|
|
143
|
+
"""The text of a whole-line or trailing comment, or None."""
|
|
144
|
+
m = _COMMENT.match(line)
|
|
145
|
+
if m:
|
|
146
|
+
return m.group(2) or None
|
|
147
|
+
for marker in (" # ", " // "):
|
|
148
|
+
if marker in line:
|
|
149
|
+
return line.split(marker, 1)[1].strip() or None
|
|
150
|
+
return None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Evidence scope: which commit, PR and issue text is about *this* code.
|
|
2
|
+
|
|
3
|
+
A Decision Record covers one file's lines from one commit, but commit messages, PR
|
|
4
|
+
bodies and issues describe the whole change. When the change touched only this
|
|
5
|
+
source file, its text is about this code. When it touched many, a sentence counts
|
|
6
|
+
as evidence for this code only if it names the code: the file, or a symbol or
|
|
7
|
+
distinctive identifier defined in or used by the anchored lines. Everything else
|
|
8
|
+
is change-level context: worth showing, but never quoted as this code's reason.
|
|
9
|
+
|
|
10
|
+
Matching is by explicit mention, not word overlap: generic words such as
|
|
11
|
+
"index", "cache" or "file" appear everywhere and would tie any sentence to any
|
|
12
|
+
file.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import PurePosixPath
|
|
20
|
+
|
|
21
|
+
from fence.decisions.reasons import paragraphs, sentences
|
|
22
|
+
from fence.globs import match_any
|
|
23
|
+
|
|
24
|
+
# File stems too generic to identify a file when they appear in prose.
|
|
25
|
+
GENERIC_STEMS = {"index", "main", "init", "__init__", "utils", "util", "base", "common", "core",
|
|
26
|
+
"types", "helpers", "lib", "app", "mod", "api", "test", "tests", "setup"}
|
|
27
|
+
_IDENT = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")
|
|
28
|
+
_TEST_PREFIX = re.compile(r"^(test_|tests?)|(_test|_spec|\.test|\.spec|tests?)$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Scope:
|
|
33
|
+
focused: bool # the change touched no other source file
|
|
34
|
+
source_files: int # source files the change touched (tests and docs excluded)
|
|
35
|
+
names: frozenset[str] # lower-case names that identify this code in prose
|
|
36
|
+
other_files: frozenset[str] = frozenset() # names of the change's other files
|
|
37
|
+
# Symbol names, matched with their exact case: code identifiers are case-sensitive,
|
|
38
|
+
# and a class `Repo` or method `load` is not mentioned by the words "repo" or "load".
|
|
39
|
+
symbols: frozenset[str] = frozenset()
|
|
40
|
+
# The file's bare stem ("text" for fence/text.py). Usually an ordinary word, so it
|
|
41
|
+
# names the code only in its own sentence, never the sentences after it, unless it
|
|
42
|
+
# is written as code (`text`, fence.text, text.clean).
|
|
43
|
+
stems: frozenset[str] = frozenset()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _distinctive(ident: str) -> bool:
|
|
47
|
+
"""CamelCase or snake_case identifiers long enough not to be ordinary words."""
|
|
48
|
+
if len(ident) < 6:
|
|
49
|
+
return False
|
|
50
|
+
return "_" in ident.strip("_") or bool(re.search(r"[a-z][A-Z]", ident))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_scope(path: str, changed: list[tuple[str, int]], code_lines: list[str],
|
|
54
|
+
symbol_names: list[str], *, test_pattern: re.Pattern[str],
|
|
55
|
+
low_signal: list[str]) -> Scope:
|
|
56
|
+
"""`changed` is (path, lines added + deleted) for every file in the change."""
|
|
57
|
+
sources = {p for p, n in changed
|
|
58
|
+
if n > 0 and not test_pattern.search(p) and not match_any(p, low_signal)}
|
|
59
|
+
focused = sources <= {path}
|
|
60
|
+
names = _file_names(path)
|
|
61
|
+
stems = {n for n in names if "." not in n}
|
|
62
|
+
names -= stems
|
|
63
|
+
symbols ={part for q in symbol_names for part in q.split(".")
|
|
64
|
+
if len(part) >= 4 and part != "<module>"}
|
|
65
|
+
for line in code_lines:
|
|
66
|
+
for ident in _IDENT.findall(line):
|
|
67
|
+
if _distinctive(ident):
|
|
68
|
+
names.add(ident.lower())
|
|
69
|
+
others: set[str] = set()
|
|
70
|
+
for p, _ in changed:
|
|
71
|
+
if p != path:
|
|
72
|
+
others |= _file_names(p)
|
|
73
|
+
return Scope(focused, len(sources), frozenset(names), frozenset(others - names - stems),
|
|
74
|
+
frozenset(symbols), frozenset(stems))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _file_names(path: str) -> set[str]:
|
|
78
|
+
pp = PurePosixPath(path)
|
|
79
|
+
out = {pp.name.lower()}
|
|
80
|
+
if pp.stem.lower() not in GENERIC_STEMS and len(pp.stem) >= 4:
|
|
81
|
+
out.add(pp.stem.lower())
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def mentions(sentence: str, names: frozenset[str], exact: frozenset[str] = frozenset()) -> bool:
|
|
86
|
+
low = sentence.lower()
|
|
87
|
+
return (any(re.search(rf"(?<!\w){re.escape(n)}(?!\w)", low) for n in names)
|
|
88
|
+
or any(re.search(rf"(?<!\w){re.escape(n)}(?!\w)", sentence) for n in exact))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _as_code(sentence: str, stems: frozenset[str]) -> bool:
|
|
92
|
+
"""A stem written as code: in backticks, or joined to a path or dotted name."""
|
|
93
|
+
low = sentence.lower()
|
|
94
|
+
return any(re.search(rf"`{re.escape(n)}`|[\w/][./]{re.escape(n)}(?!\w)|"
|
|
95
|
+
rf"(?<![\w.]){re.escape(n)}\.\w", low) for n in stems)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def relevant_text(text: str, scope: Scope) -> str:
|
|
99
|
+
"""The part of `text` that is evidence for this code: all of it for a focused
|
|
100
|
+
change, otherwise only the sentences that name this code."""
|
|
101
|
+
if scope.focused:
|
|
102
|
+
return text
|
|
103
|
+
# A sentence right after one that names this code, in the same paragraph, continues
|
|
104
|
+
# it ("fence/text.py strips escapes. Commit messages are attacker-controlled, ...")
|
|
105
|
+
# unless it names another file in the change. A bare stem ("uses text labels") counts
|
|
106
|
+
# for its own sentence only. Runs stay joined so a quote built from adjacent
|
|
107
|
+
# sentences is still verbatim.
|
|
108
|
+
runs: list[str] = []
|
|
109
|
+
for para in paragraphs(text):
|
|
110
|
+
run: list[str] = []
|
|
111
|
+
for s in para:
|
|
112
|
+
if mentions(s, scope.names, scope.symbols) or _as_code(s, scope.stems):
|
|
113
|
+
run.append(s)
|
|
114
|
+
continue
|
|
115
|
+
if run and not mentions(s, scope.other_files):
|
|
116
|
+
run.append(s)
|
|
117
|
+
continue
|
|
118
|
+
if run:
|
|
119
|
+
runs.append(" ".join(run))
|
|
120
|
+
run = []
|
|
121
|
+
if mentions(s, scope.stems):
|
|
122
|
+
runs.append(s)
|
|
123
|
+
if run:
|
|
124
|
+
runs.append(" ".join(run))
|
|
125
|
+
return "\n\n".join(runs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def change_level_text(text: str, scope: Scope) -> str:
|
|
129
|
+
"""Sentences about the change as a whole: those that name no other file in it.
|
|
130
|
+
A sentence naming another file is that file's reason, not context for this one."""
|
|
131
|
+
return "\n\n".join(s for s in sentences(text) if not mentions(s, scope.other_files))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def related_tests(test_paths: list[str], path: str, scope: Scope) -> list[str]:
|
|
135
|
+
"""Tests from the same change that plausibly exercise this file: any test for a
|
|
136
|
+
focused change, otherwise tests whose name matches the file's name."""
|
|
137
|
+
if scope.focused:
|
|
138
|
+
return test_paths
|
|
139
|
+
stem = PurePosixPath(path).stem.lower()
|
|
140
|
+
out = []
|
|
141
|
+
for t in test_paths:
|
|
142
|
+
tstem = _TEST_PREFIX.sub("", PurePosixPath(t).stem.lower()).strip("_.-")
|
|
143
|
+
if tstem and (tstem == stem or (len(tstem) >= 4 and tstem in stem)
|
|
144
|
+
or (len(stem) >= 4 and stem in tstem)):
|
|
145
|
+
out.append(t)
|
|
146
|
+
return out
|
|
File without changes
|
fence/diffing/ranges.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The unit of work between diff parsing and git tracing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RemovedRange:
|
|
10
|
+
"""Consecutive pre-image lines a diff removes or changes.
|
|
11
|
+
|
|
12
|
+
`start`..`end` are 1-based, inclusive line numbers in the file at the base revision.
|
|
13
|
+
`lines` is the removed text, without newlines, exactly as the diff shows it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
start: int
|
|
18
|
+
end: int
|
|
19
|
+
lines: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.start < 1 or self.end < self.start:
|
|
23
|
+
raise ValueError(f"bad range {self.start},{self.end}")
|
|
24
|
+
if len(self.lines) != self.end - self.start + 1:
|
|
25
|
+
raise ValueError("line count does not match range")
|
fence/diffing/symbols.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Find the function or class that encloses a line of Python source.
|
|
2
|
+
|
|
3
|
+
Kept as the stable Day 1 entry point; the work is done by the Python language
|
|
4
|
+
plugin. For any language, use `fence.lang.enclosing_symbol(path, source, line)`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from fence.lang import MODULE, Symbol
|
|
10
|
+
from fence.lang import enclosing_symbol as _enclosing
|
|
11
|
+
|
|
12
|
+
__all__ = ["MODULE", "Symbol", "enclosing_symbol"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def enclosing_symbol(source: str, line: int) -> Symbol | None:
|
|
16
|
+
"""Return the innermost function/class containing `line` (1-based).
|
|
17
|
+
|
|
18
|
+
Returns None if the source does not parse or the line is past the end of the file.
|
|
19
|
+
Lines outside any def/class return the `<module>` symbol.
|
|
20
|
+
"""
|
|
21
|
+
return _enclosing("x.py", source, line)
|
fence/eval/__init__.py
ADDED
|
File without changes
|