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
fence/config.py ADDED
@@ -0,0 +1,209 @@
1
+ """fence.toml: every option, its default, and validation.
2
+
3
+ Zero config must work, so every field has a default. Unknown keys are errors rather
4
+ than silently ignored, because a typo in a policy file would otherwise weaken it
5
+ without anyone noticing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tomllib
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
16
+
17
+ CONFIG_FILE = "fence.toml"
18
+ BANDS = ("low", "medium", "high", "critical")
19
+ Band = Literal["low", "medium", "high", "critical"]
20
+ Grade = Literal["A", "B", "C", "D"]
21
+
22
+
23
+ class ConfigError(Exception):
24
+ """fence.toml could not be read or did not validate."""
25
+
26
+
27
+ class _Section(BaseModel):
28
+ model_config = ConfigDict(extra="forbid", frozen=True)
29
+
30
+
31
+ class IndexConfig(_Section):
32
+ refs: list[str] = ["HEAD"]
33
+ history_depth: int | None = Field(None, ge=1, description="Max commits to index; None = all")
34
+ since_date: str | None = Field(None, description="ISO date; older commits are skipped")
35
+ include: list[str] = ["**"]
36
+ exclude: list[str] = [
37
+ "vendor/**",
38
+ "third_party/**",
39
+ "node_modules/**",
40
+ "**/*.min.js",
41
+ "**/*.lock",
42
+ "**/package-lock.json",
43
+ ]
44
+ languages: list[str] = ["python", "javascript", "typescript", "go", "java"]
45
+ ignore_revs_file: str = ".git-blame-ignore-revs"
46
+ max_file_size: int = Field(1_000_000, ge=1_000)
47
+ follow_renames_sensitivity: int = Field(50, ge=1, le=100, description="git -M percent")
48
+ batch_size: int = Field(500, ge=1, le=10_000)
49
+ bulk_commit_files: int = Field(100, ge=2, description="Commits touching more are 'bulk'")
50
+
51
+
52
+ class GithubConfig(_Section):
53
+ enabled: Literal["auto", "off"] = "auto"
54
+ token_env: str = "GITHUB_TOKEN" # noqa: S105 - the env var's name, not a secret
55
+ api_base_url: str = "https://api.github.com"
56
+ rate_limit_policy: Literal["stop", "wait"] = "stop"
57
+ min_remaining: int = Field(50, ge=0)
58
+ sync_prs: bool = True
59
+ sync_issues: bool = True
60
+ sync_review_comments: bool = True
61
+ max_age_days: int | None = Field(None, ge=1)
62
+ max_requests_per_run: int = Field(2_000, ge=1)
63
+
64
+ @field_validator("api_base_url")
65
+ @classmethod
66
+ def _https_only(cls, v: str) -> str:
67
+ # A token goes to this host; plain http would leak it.
68
+ if not v.startswith("https://"):
69
+ raise ValueError("api_base_url must use https://")
70
+ return v.rstrip("/")
71
+
72
+
73
+ class BandThresholds(_Section):
74
+ medium: int = Field(25, ge=1, le=100)
75
+ high: int = Field(60, ge=1, le=100)
76
+ critical: int = Field(85, ge=1, le=100)
77
+
78
+
79
+ class ScoringConfig(_Section):
80
+ weights: dict[str, float] = {}
81
+ bands: BandThresholds = BandThresholds()
82
+ min_confidence_to_warn: float = Field(0.3, ge=0.0, le=1.0)
83
+ # Below this Change Risk a touched decision is CLEAR, not VERIFY (noise budget).
84
+ verify_min_risk: int = Field(50, ge=1, le=100)
85
+ # Only decisions with a stated reason (A/B) of these types warn from the medium band.
86
+ high_stakes_types: list[str] = ["incident", "security", "data-integrity", "concurrency"]
87
+ # Paths whose removals rarely matter (docs, build/config, generated completion
88
+ # scripts). Change Risk there is scaled by weights["risk.low_signal_share"].
89
+ low_signal_paths: list[str] = [
90
+ "docs/**", "doc/**", "**/*.md", "**/*.rst", "**/*.txt", "**/conf.py", "**/*.cfg",
91
+ "**/*.ini", "**/*.toml", "**/*.json", "**/*.yaml", "**/*.yml", "examples/**",
92
+ "example/**", "**/*completion*",
93
+ ]
94
+ grade_floor_for_block: Grade = "C"
95
+
96
+ def band(self, score: float) -> Band:
97
+ if score >= self.bands.critical:
98
+ return "critical"
99
+ if score >= self.bands.high:
100
+ return "high"
101
+ if score >= self.bands.medium:
102
+ return "medium"
103
+ return "low"
104
+
105
+
106
+ class PathOverride(_Section):
107
+ fail_on_band: Band | None = None
108
+ require_ack_for: Band | None = None
109
+ risk_bonus: int = Field(0, ge=0, le=50)
110
+
111
+
112
+ class PolicyConfig(_Section):
113
+ fail_on_band: Band = "high"
114
+ require_ack_for: Band = "critical"
115
+ ignore_paths: list[str] = []
116
+ path_overrides: dict[str, PathOverride] = {
117
+ "**/auth/**": PathOverride(fail_on_band="medium", risk_bonus=10),
118
+ "**/payments/**": PathOverride(fail_on_band="medium", risk_bonus=10),
119
+ "**/migrations/**": PathOverride(fail_on_band="medium", risk_bonus=10),
120
+ }
121
+ allow_bypass_env: str = "FENCE_BYPASS"
122
+ ack_expiry_days: int | None = Field(90, ge=1)
123
+ ack_ledger: str = ".fence/acks.jsonl"
124
+
125
+
126
+ class LlmConfig(_Section):
127
+ enabled: bool = False
128
+ provider: str = "anthropic"
129
+ model: str = ""
130
+ api_key_env: str = "ANTHROPIC_API_KEY"
131
+ max_tokens_per_run: int = Field(50_000, ge=0)
132
+ max_usd_per_run: float = Field(0.50, ge=0.0)
133
+ cache_dir: str | None = None
134
+ redact_paths: list[str] = ["secrets/**", "**/*.env", "**/.env"]
135
+
136
+
137
+ class OutputConfig(_Section):
138
+ format: Literal["human", "json"] = "human"
139
+ color: Literal["auto", "always", "never"] = "auto"
140
+ verbosity: int = Field(0, ge=0, le=2)
141
+ max_items: int = Field(10, ge=1, le=500)
142
+ link_style: Literal["url", "sha"] = "url"
143
+
144
+
145
+ class McpConfig(_Section):
146
+ max_response_tokens: int = Field(1_500, ge=200, le=20_000)
147
+ detail_default: Literal["brief", "full"] = "brief"
148
+ write_tools_enabled: bool = False
149
+ guardrail_band: Band = "high"
150
+
151
+
152
+ class PerformanceConfig(_Section):
153
+ worker_count: int = Field(4, ge=1, le=64)
154
+ memory_ceiling_mb: int = Field(512, ge=64)
155
+ # Outside the working tree by default: the index holds private repo content.
156
+ cache_dir: str = "~/.cache/fence"
157
+ git_timeout_seconds: float = Field(10.0, gt=0, le=600)
158
+ log_timeout_seconds: float = Field(600.0, gt=0, le=86_400)
159
+ hook_budget_seconds: float = Field(2.0, gt=0, le=60)
160
+
161
+
162
+ class Config(_Section):
163
+ index: IndexConfig = IndexConfig()
164
+ github: GithubConfig = GithubConfig()
165
+ scoring: ScoringConfig = ScoringConfig()
166
+ policy: PolicyConfig = PolicyConfig()
167
+ llm: LlmConfig = LlmConfig()
168
+ output: OutputConfig = OutputConfig()
169
+ mcp: McpConfig = McpConfig()
170
+ performance: PerformanceConfig = PerformanceConfig()
171
+ source: str | None = Field(None, description="Where this config came from")
172
+
173
+ @property
174
+ def cache_dir(self) -> Path:
175
+ override = os.environ.get("FENCE_CACHE_DIR")
176
+ return Path(override or self.performance.cache_dir).expanduser()
177
+
178
+
179
+ def load_config(repo_root: Path | None = None, *, text: str | None = None) -> Config:
180
+ """Read fence.toml from `repo_root` (or parse `text`). No file -> all defaults."""
181
+ source = "<defaults>"
182
+ if text is None and repo_root is not None:
183
+ path = Path(repo_root) / CONFIG_FILE
184
+ if path.is_file():
185
+ if path.stat().st_size > 256_000:
186
+ raise ConfigError(f"{path} is larger than 256 KB")
187
+ text = path.read_text(encoding="utf-8")
188
+ source = str(path)
189
+ elif text is not None:
190
+ source = "<text>"
191
+ return parse_config(text or "", source=source)
192
+
193
+
194
+ def parse_config(text: str, *, source: str = "<text>") -> Config:
195
+ try:
196
+ data = tomllib.loads(text)
197
+ except tomllib.TOMLDecodeError as e:
198
+ raise ConfigError(f"{source}: invalid TOML: {e}") from None
199
+ try:
200
+ return Config.model_validate({**data, "source": source})
201
+ except ValidationError as e:
202
+ problems = "; ".join(
203
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in e.errors()
204
+ )
205
+ raise ConfigError(f"{source}: {problems}") from None
206
+
207
+
208
+ def bands_at_or_above(band: Band) -> tuple[Band, ...]:
209
+ return BANDS[BANDS.index(band) :] # type: ignore[return-value]
fence/context.py ADDED
@@ -0,0 +1,77 @@
1
+ """Everything one Fence operation needs: repo, config, index, and what we cannot see."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from fence.config import Config, load_config
11
+ from fence.git.runner import GitError, Repo
12
+ from fence.index.store import Store, db_path_for
13
+
14
+ _GITHUB = re.compile(r"^(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)"
15
+ r"([\w.-]+)/([\w.-]+?)(?:\.git)?/?$")
16
+
17
+
18
+ def github_slug(remote_url: str) -> tuple[str, str] | None:
19
+ m = _GITHUB.match(remote_url.strip())
20
+ return (m.group(1), m.group(2)) if m else None
21
+
22
+
23
+ @dataclass
24
+ class Context:
25
+ repo: Repo
26
+ cfg: Config
27
+ store: Store
28
+ slug: tuple[str, str] | None = None
29
+ notes: list[str] = field(default_factory=list)
30
+ # Backtests set this to the commit time being replayed, so no later history leaks in.
31
+ as_of: int | None = None
32
+ cache: dict = field(default_factory=dict) # per-operation memo (manifests etc.)
33
+
34
+ @classmethod
35
+ def open(cls, path: str | Path = ".", *, create: bool = True) -> Context:
36
+ repo = Repo.open(path)
37
+ cfg = load_config(repo.root)
38
+ repo = Repo(repo.root, timeout=cfg.performance.git_timeout_seconds)
39
+ store = Store.open(db_path_for(cfg.cache_dir, repo.root), create=create)
40
+ slug = None
41
+ try:
42
+ slug = github_slug(repo.run(["remote", "get-url", "origin"]))
43
+ except GitError:
44
+ pass
45
+ return cls(repo, cfg, store, slug)
46
+
47
+ @property
48
+ def web_base(self) -> str | None:
49
+ return f"https://github.com/{self.slug[0]}/{self.slug[1]}" if self.slug else None
50
+
51
+ @property
52
+ def github_token(self) -> str | None:
53
+ if self.cfg.github.enabled == "off":
54
+ return None
55
+ return os.environ.get(self.cfg.github.token_env) or None
56
+
57
+ @property
58
+ def offline(self) -> bool:
59
+ """True when PR/issue text cannot be read (no token, no GitHub remote, or disabled)."""
60
+ has_prs = self.store.conn.execute("SELECT 1 FROM prs LIMIT 1").fetchone() is not None
61
+ return not has_prs
62
+
63
+ @property
64
+ def shallow(self) -> bool:
65
+ return self.store.get_meta("shallow") == "1"
66
+
67
+ def commit_url(self, sha: str) -> str | None:
68
+ return f"{self.web_base}/commit/{sha}" if self.web_base else None
69
+
70
+ def pr_url(self, n: int) -> str | None:
71
+ return f"{self.web_base}/pull/{n}" if self.web_base else None
72
+
73
+ def issue_url(self, n: int) -> str | None:
74
+ return f"{self.web_base}/issues/{n}" if self.web_base else None
75
+
76
+ def close(self) -> None:
77
+ self.store.close()
File without changes