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,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
@@ -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")
@@ -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