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/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
|