regix 0.1.1__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.
- regix/__init__.py +132 -0
- regix/backends/__init__.py +54 -0
- regix/backends/coverage_backend.py +110 -0
- regix/backends/docstring_backend.py +66 -0
- regix/backends/lizard_backend.py +71 -0
- regix/backends/radon_backend.py +92 -0
- regix/backends/vallm_backend.py +74 -0
- regix/cache.py +97 -0
- regix/cli.py +297 -0
- regix/compare.py +175 -0
- regix/config.py +202 -0
- regix/exceptions.py +54 -0
- regix/gates.py +52 -0
- regix/git.py +121 -0
- regix/history.py +100 -0
- regix/integrations/__init__.py +64 -0
- regix/models.py +387 -0
- regix/report.py +133 -0
- regix/snapshot.py +137 -0
- regix-0.1.1.dist-info/METADATA +382 -0
- regix-0.1.1.dist-info/RECORD +25 -0
- regix-0.1.1.dist-info/WHEEL +5 -0
- regix-0.1.1.dist-info/entry_points.txt +2 -0
- regix-0.1.1.dist-info/licenses/LICENSE +183 -0
- regix-0.1.1.dist-info/top_level.txt +1 -0
regix/__init__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Regix — Regression Index for Python code quality.
|
|
2
|
+
|
|
3
|
+
Detect, measure and report code quality regressions between git versions
|
|
4
|
+
at function, class and line granularity.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from regix.config import RegressionConfig
|
|
8
|
+
from regix.models import (
|
|
9
|
+
CommitMetrics,
|
|
10
|
+
GateCheck,
|
|
11
|
+
GateResult,
|
|
12
|
+
HistoryRegression,
|
|
13
|
+
HistoryReport,
|
|
14
|
+
Improvement,
|
|
15
|
+
MetricDelta,
|
|
16
|
+
Regression,
|
|
17
|
+
RegressionReport,
|
|
18
|
+
Snapshot,
|
|
19
|
+
SymbolMetrics,
|
|
20
|
+
TrendLine,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Ensure backends are registered on import
|
|
24
|
+
from regix.backends import docstring_backend as _docstring # noqa: F401
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from regix.backends import lizard_backend as _lizard # noqa: F401
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
try:
|
|
31
|
+
from regix.backends import radon_backend as _radon # noqa: F401
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
try:
|
|
35
|
+
from regix.backends import coverage_backend as _cov # noqa: F401
|
|
36
|
+
except ImportError:
|
|
37
|
+
pass
|
|
38
|
+
try:
|
|
39
|
+
from regix.backends import vallm_backend as _vallm # noqa: F401
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Regix:
|
|
45
|
+
"""Main entry point — wraps snapshot, compare, and history."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config: RegressionConfig | str | None = None,
|
|
50
|
+
workdir: str = ".",
|
|
51
|
+
):
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
if isinstance(config, str):
|
|
55
|
+
self.config = RegressionConfig.from_file(config)
|
|
56
|
+
elif config is None:
|
|
57
|
+
try:
|
|
58
|
+
self.config = RegressionConfig.from_file(workdir)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
self.config = RegressionConfig()
|
|
61
|
+
else:
|
|
62
|
+
self.config = config
|
|
63
|
+
self.config.workdir = str(Path(workdir).resolve())
|
|
64
|
+
self.config.apply_env_overrides()
|
|
65
|
+
|
|
66
|
+
def snapshot(self, ref: str = "HEAD", use_cache: bool = True) -> Snapshot:
|
|
67
|
+
"""Capture metrics at a git ref."""
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from regix.snapshot import capture
|
|
70
|
+
|
|
71
|
+
return capture(ref, Path(self.config.workdir), self.config)
|
|
72
|
+
|
|
73
|
+
def compare(
|
|
74
|
+
self,
|
|
75
|
+
ref_before: str = "HEAD~1",
|
|
76
|
+
ref_after: str = "HEAD",
|
|
77
|
+
use_cache: bool = True,
|
|
78
|
+
) -> RegressionReport:
|
|
79
|
+
"""Compare metrics between two refs."""
|
|
80
|
+
from regix.compare import compare as do_compare
|
|
81
|
+
|
|
82
|
+
snap_a = self.snapshot(ref_before, use_cache)
|
|
83
|
+
snap_b = self.snapshot(ref_after, use_cache)
|
|
84
|
+
return do_compare(snap_a, snap_b, self.config)
|
|
85
|
+
|
|
86
|
+
def compare_local(self, ref_before: str = "HEAD") -> RegressionReport:
|
|
87
|
+
"""Compare a git ref against the current working tree."""
|
|
88
|
+
return self.compare(ref_before, "local", use_cache=False)
|
|
89
|
+
|
|
90
|
+
def history(
|
|
91
|
+
self,
|
|
92
|
+
depth: int = 20,
|
|
93
|
+
ref: str = "HEAD",
|
|
94
|
+
metrics: list[str] | None = None,
|
|
95
|
+
) -> HistoryReport:
|
|
96
|
+
"""Walk commits and return a metric timeline."""
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
from regix.history import build_history
|
|
99
|
+
|
|
100
|
+
return build_history(
|
|
101
|
+
depth=depth, ref=ref,
|
|
102
|
+
workdir=Path(self.config.workdir),
|
|
103
|
+
config=self.config,
|
|
104
|
+
metrics_filter=metrics,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def check_gates(self, ref: str = "HEAD") -> GateResult:
|
|
108
|
+
"""Check current state against absolute thresholds."""
|
|
109
|
+
from regix.gates import check_gates
|
|
110
|
+
|
|
111
|
+
snap = self.snapshot(ref)
|
|
112
|
+
return check_gates(snap, self.config)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = [
|
|
116
|
+
"Regix",
|
|
117
|
+
"RegressionConfig",
|
|
118
|
+
"Snapshot",
|
|
119
|
+
"SymbolMetrics",
|
|
120
|
+
"MetricDelta",
|
|
121
|
+
"Regression",
|
|
122
|
+
"Improvement",
|
|
123
|
+
"RegressionReport",
|
|
124
|
+
"CommitMetrics",
|
|
125
|
+
"HistoryRegression",
|
|
126
|
+
"HistoryReport",
|
|
127
|
+
"TrendLine",
|
|
128
|
+
"GateCheck",
|
|
129
|
+
"GateResult",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Backend ABC and registry for static analysis tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from regix.config import RegressionConfig
|
|
9
|
+
from regix.models import SymbolMetrics
|
|
10
|
+
|
|
11
|
+
# ── Global backend registry ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
_BACKENDS: dict[str, "BackendBase"] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BackendBase(ABC):
|
|
17
|
+
"""Interface that all analysis backends must implement."""
|
|
18
|
+
|
|
19
|
+
name: str = ""
|
|
20
|
+
required_binary: str | None = None
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def is_available(self) -> bool:
|
|
24
|
+
"""Return True if the backend's dependencies are installed."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def collect(
|
|
29
|
+
self,
|
|
30
|
+
workdir: Path,
|
|
31
|
+
files: list[Path],
|
|
32
|
+
config: RegressionConfig,
|
|
33
|
+
) -> list[SymbolMetrics]:
|
|
34
|
+
"""Run analysis and return per-symbol metrics."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def version(self) -> str:
|
|
38
|
+
"""Return the version string of the backend tool."""
|
|
39
|
+
return "unknown"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_backend(backend: BackendBase) -> None:
|
|
43
|
+
"""Register a backend instance for use by Regix."""
|
|
44
|
+
_BACKENDS[backend.name] = backend
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_backend(name: str) -> BackendBase | None:
|
|
48
|
+
"""Look up a registered backend by name."""
|
|
49
|
+
return _BACKENDS.get(name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def available_backends() -> list[str]:
|
|
53
|
+
"""Return names of all registered backends."""
|
|
54
|
+
return list(_BACKENDS.keys())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Coverage backend — reads pytest-cov .coverage data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from regix.backends import BackendBase, register_backend
|
|
9
|
+
from regix.config import RegressionConfig
|
|
10
|
+
from regix.models import SymbolMetrics
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CoverageBackend(BackendBase):
|
|
14
|
+
name = "coverage"
|
|
15
|
+
required_binary = None
|
|
16
|
+
|
|
17
|
+
def is_available(self) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
import coverage as cov # noqa: F401
|
|
20
|
+
return True
|
|
21
|
+
except ImportError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def version(self) -> str:
|
|
25
|
+
try:
|
|
26
|
+
import coverage
|
|
27
|
+
return coverage.__version__
|
|
28
|
+
except (ImportError, AttributeError):
|
|
29
|
+
return "not installed"
|
|
30
|
+
|
|
31
|
+
def collect(
|
|
32
|
+
self,
|
|
33
|
+
workdir: Path,
|
|
34
|
+
files: list[Path],
|
|
35
|
+
config: RegressionConfig,
|
|
36
|
+
) -> list[SymbolMetrics]:
|
|
37
|
+
"""Read coverage from a JSON report or .coverage file."""
|
|
38
|
+
results: list[SymbolMetrics] = []
|
|
39
|
+
|
|
40
|
+
# Try JSON report first (from pytest --cov-report=json)
|
|
41
|
+
json_candidates = [
|
|
42
|
+
workdir / ".regix" / "coverage.json",
|
|
43
|
+
workdir / "coverage.json",
|
|
44
|
+
workdir / "htmlcov" / "status.json",
|
|
45
|
+
]
|
|
46
|
+
for cj in json_candidates:
|
|
47
|
+
if cj.exists():
|
|
48
|
+
return self._from_json(cj, files)
|
|
49
|
+
|
|
50
|
+
# Fallback: read .coverage SQLite via coverage API
|
|
51
|
+
cov_file = workdir / ".coverage"
|
|
52
|
+
if cov_file.exists():
|
|
53
|
+
return self._from_coverage_file(cov_file, files, workdir)
|
|
54
|
+
|
|
55
|
+
return results
|
|
56
|
+
|
|
57
|
+
def _from_json(
|
|
58
|
+
self, path: Path, files: list[Path]
|
|
59
|
+
) -> list[SymbolMetrics]:
|
|
60
|
+
results: list[SymbolMetrics] = []
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
file_data = data.get("files", {})
|
|
67
|
+
file_set = {str(f) for f in files}
|
|
68
|
+
for fname, finfo in file_data.items():
|
|
69
|
+
if file_set and fname not in file_set:
|
|
70
|
+
continue
|
|
71
|
+
summary = finfo.get("summary", {})
|
|
72
|
+
pct = summary.get("percent_covered", None)
|
|
73
|
+
if pct is not None:
|
|
74
|
+
results.append(
|
|
75
|
+
SymbolMetrics(
|
|
76
|
+
file=fname,
|
|
77
|
+
symbol=None,
|
|
78
|
+
coverage=pct,
|
|
79
|
+
raw={"covered_lines": summary.get("covered_lines", 0)},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return results
|
|
83
|
+
|
|
84
|
+
def _from_coverage_file(
|
|
85
|
+
self, cov_path: Path, files: list[Path], workdir: Path
|
|
86
|
+
) -> list[SymbolMetrics]:
|
|
87
|
+
results: list[SymbolMetrics] = []
|
|
88
|
+
try:
|
|
89
|
+
import coverage as cov_lib
|
|
90
|
+
|
|
91
|
+
cov = cov_lib.Coverage(data_file=str(cov_path))
|
|
92
|
+
cov.load()
|
|
93
|
+
data = cov.get_data()
|
|
94
|
+
for fname in data.measured_files():
|
|
95
|
+
lines = data.lines(fname) or []
|
|
96
|
+
missing = data.lines(fname) # simplified
|
|
97
|
+
total = len(lines)
|
|
98
|
+
if total > 0:
|
|
99
|
+
pct = (len(lines) / total) * 100
|
|
100
|
+
else:
|
|
101
|
+
pct = 0.0
|
|
102
|
+
results.append(
|
|
103
|
+
SymbolMetrics(file=fname, symbol=None, coverage=pct)
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
return results
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
register_backend(CoverageBackend())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Docstring backend — pure Python, ast-based docstring coverage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from regix.backends import BackendBase, register_backend
|
|
9
|
+
from regix.config import RegressionConfig
|
|
10
|
+
from regix.models import SymbolMetrics
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocstringBackend(BackendBase):
|
|
14
|
+
name = "docstring"
|
|
15
|
+
required_binary = None
|
|
16
|
+
|
|
17
|
+
def is_available(self) -> bool:
|
|
18
|
+
return True # Built-in, always available
|
|
19
|
+
|
|
20
|
+
def version(self) -> str:
|
|
21
|
+
import sys
|
|
22
|
+
return f"ast (Python {sys.version_info.major}.{sys.version_info.minor})"
|
|
23
|
+
|
|
24
|
+
def collect(
|
|
25
|
+
self,
|
|
26
|
+
workdir: Path,
|
|
27
|
+
files: list[Path],
|
|
28
|
+
config: RegressionConfig,
|
|
29
|
+
) -> list[SymbolMetrics]:
|
|
30
|
+
results: list[SymbolMetrics] = []
|
|
31
|
+
for fpath in files:
|
|
32
|
+
full = workdir / fpath
|
|
33
|
+
if not full.exists() or full.suffix != ".py":
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
source = full.read_text(encoding="utf-8")
|
|
37
|
+
tree = ast.parse(source, filename=str(full))
|
|
38
|
+
except (SyntaxError, UnicodeDecodeError, OSError):
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
total = 0
|
|
42
|
+
documented = 0
|
|
43
|
+
for node in ast.walk(tree):
|
|
44
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
45
|
+
# Skip private/dunder unless it's __init__
|
|
46
|
+
name = node.name
|
|
47
|
+
if name.startswith("_") and name != "__init__":
|
|
48
|
+
continue
|
|
49
|
+
total += 1
|
|
50
|
+
docstring = ast.get_docstring(node)
|
|
51
|
+
if docstring:
|
|
52
|
+
documented += 1
|
|
53
|
+
|
|
54
|
+
pct = (documented / total * 100) if total > 0 else 100.0
|
|
55
|
+
results.append(
|
|
56
|
+
SymbolMetrics(
|
|
57
|
+
file=str(fpath),
|
|
58
|
+
symbol=None,
|
|
59
|
+
docstring_coverage=round(pct, 1),
|
|
60
|
+
raw={"total_public": total, "documented": documented},
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return results
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
register_backend(DocstringBackend())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Lizard backend — cyclomatic complexity and function length."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from regix.backends import BackendBase, register_backend
|
|
9
|
+
from regix.config import RegressionConfig
|
|
10
|
+
from regix.models import SymbolMetrics
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LizardBackend(BackendBase):
|
|
14
|
+
name = "lizard"
|
|
15
|
+
required_binary = "lizard"
|
|
16
|
+
|
|
17
|
+
def is_available(self) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
import lizard # noqa: F401
|
|
20
|
+
return True
|
|
21
|
+
except ImportError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def version(self) -> str:
|
|
25
|
+
try:
|
|
26
|
+
import lizard
|
|
27
|
+
return getattr(lizard, "__version__", "unknown")
|
|
28
|
+
except ImportError:
|
|
29
|
+
return "not installed"
|
|
30
|
+
|
|
31
|
+
def collect(
|
|
32
|
+
self,
|
|
33
|
+
workdir: Path,
|
|
34
|
+
files: list[Path],
|
|
35
|
+
config: RegressionConfig,
|
|
36
|
+
) -> list[SymbolMetrics]:
|
|
37
|
+
try:
|
|
38
|
+
import lizard
|
|
39
|
+
except ImportError:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
results: list[SymbolMetrics] = []
|
|
43
|
+
for fpath in files:
|
|
44
|
+
full = workdir / fpath
|
|
45
|
+
if not full.exists() or not full.is_file():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
analysis = lizard.analyze_file(str(full))
|
|
49
|
+
except Exception:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
for func in analysis.function_list:
|
|
53
|
+
results.append(
|
|
54
|
+
SymbolMetrics(
|
|
55
|
+
file=str(fpath),
|
|
56
|
+
symbol=func.name,
|
|
57
|
+
line_start=func.start_line,
|
|
58
|
+
line_end=func.end_line,
|
|
59
|
+
cc=func.cyclomatic_complexity,
|
|
60
|
+
length=func.nloc,
|
|
61
|
+
raw={
|
|
62
|
+
"token_count": func.token_count,
|
|
63
|
+
"parameter_count": len(func.parameters),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return results
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Auto-register
|
|
71
|
+
register_backend(LizardBackend())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Radon backend — maintainability index and raw CC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from regix.backends import BackendBase, register_backend
|
|
8
|
+
from regix.config import RegressionConfig
|
|
9
|
+
from regix.models import SymbolMetrics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RadonBackend(BackendBase):
|
|
13
|
+
name = "radon"
|
|
14
|
+
required_binary = None
|
|
15
|
+
|
|
16
|
+
def is_available(self) -> bool:
|
|
17
|
+
try:
|
|
18
|
+
import radon.complexity # noqa: F401
|
|
19
|
+
import radon.metrics # noqa: F401
|
|
20
|
+
return True
|
|
21
|
+
except ImportError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def version(self) -> str:
|
|
25
|
+
try:
|
|
26
|
+
import radon
|
|
27
|
+
return getattr(radon, "__version__", "unknown")
|
|
28
|
+
except ImportError:
|
|
29
|
+
return "not installed"
|
|
30
|
+
|
|
31
|
+
def collect(
|
|
32
|
+
self,
|
|
33
|
+
workdir: Path,
|
|
34
|
+
files: list[Path],
|
|
35
|
+
config: RegressionConfig,
|
|
36
|
+
) -> list[SymbolMetrics]:
|
|
37
|
+
try:
|
|
38
|
+
from radon.complexity import cc_visit
|
|
39
|
+
from radon.metrics import mi_visit
|
|
40
|
+
except ImportError:
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
results: list[SymbolMetrics] = []
|
|
44
|
+
for fpath in files:
|
|
45
|
+
full = workdir / fpath
|
|
46
|
+
if not full.exists() or full.suffix != ".py":
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
source = full.read_text(encoding="utf-8")
|
|
50
|
+
except (OSError, UnicodeDecodeError):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
# Maintainability index (module-level)
|
|
54
|
+
try:
|
|
55
|
+
mi = mi_visit(source, multi=True)
|
|
56
|
+
except Exception:
|
|
57
|
+
mi = None
|
|
58
|
+
|
|
59
|
+
# CC per function/class
|
|
60
|
+
try:
|
|
61
|
+
cc_results = cc_visit(source)
|
|
62
|
+
except Exception:
|
|
63
|
+
cc_results = []
|
|
64
|
+
|
|
65
|
+
# Module-level entry with MI
|
|
66
|
+
results.append(
|
|
67
|
+
SymbolMetrics(
|
|
68
|
+
file=str(fpath),
|
|
69
|
+
symbol=None,
|
|
70
|
+
mi=mi,
|
|
71
|
+
raw={"radon_mi": mi},
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
for block in cc_results:
|
|
76
|
+
results.append(
|
|
77
|
+
SymbolMetrics(
|
|
78
|
+
file=str(fpath),
|
|
79
|
+
symbol=block.name,
|
|
80
|
+
line_start=block.lineno,
|
|
81
|
+
line_end=block.endline,
|
|
82
|
+
cc=block.complexity,
|
|
83
|
+
raw={
|
|
84
|
+
"radon_rank": block.letter,
|
|
85
|
+
"radon_classname": getattr(block, "classname", None),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return results
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
register_backend(RadonBackend())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Vallm backend — LLM-based code quality scoring via vallm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from regix.backends import BackendBase, register_backend
|
|
11
|
+
from regix.config import RegressionConfig
|
|
12
|
+
from regix.models import SymbolMetrics
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VallmBackend(BackendBase):
|
|
16
|
+
name = "vallm"
|
|
17
|
+
required_binary = "vallm"
|
|
18
|
+
|
|
19
|
+
def is_available(self) -> bool:
|
|
20
|
+
return shutil.which("vallm") is not None
|
|
21
|
+
|
|
22
|
+
def version(self) -> str:
|
|
23
|
+
try:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["vallm", "--version"],
|
|
26
|
+
capture_output=True, text=True, check=False,
|
|
27
|
+
)
|
|
28
|
+
return result.stdout.strip() or "unknown"
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return "not installed"
|
|
31
|
+
|
|
32
|
+
def collect(
|
|
33
|
+
self,
|
|
34
|
+
workdir: Path,
|
|
35
|
+
files: list[Path],
|
|
36
|
+
config: RegressionConfig,
|
|
37
|
+
) -> list[SymbolMetrics]:
|
|
38
|
+
if not self.is_available():
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
results: list[SymbolMetrics] = []
|
|
42
|
+
try:
|
|
43
|
+
proc = subprocess.run(
|
|
44
|
+
["vallm", "batch", str(workdir), "--recursive", "--format", "json"],
|
|
45
|
+
capture_output=True, text=True, check=False,
|
|
46
|
+
cwd=str(workdir),
|
|
47
|
+
)
|
|
48
|
+
if proc.returncode != 0:
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
data = json.loads(proc.stdout)
|
|
52
|
+
file_set = {str(f) for f in files} if files else set()
|
|
53
|
+
|
|
54
|
+
for entry in data if isinstance(data, list) else data.get("files", []):
|
|
55
|
+
fname = entry.get("file", "")
|
|
56
|
+
if file_set and fname not in file_set:
|
|
57
|
+
continue
|
|
58
|
+
score = entry.get("score", entry.get("quality_score"))
|
|
59
|
+
if score is not None:
|
|
60
|
+
results.append(
|
|
61
|
+
SymbolMetrics(
|
|
62
|
+
file=fname,
|
|
63
|
+
symbol=None,
|
|
64
|
+
quality_score=float(score),
|
|
65
|
+
raw=entry,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
except (json.JSONDecodeError, OSError, FileNotFoundError):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return results
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
register_backend(VallmBackend())
|
regix/cache.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Content-addressed snapshot cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from regix.models import Snapshot
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cache_dir(config_dir: str = "~/.cache/regix") -> Path:
|
|
14
|
+
"""Resolve cache directory (XDG-compliant)."""
|
|
15
|
+
import os
|
|
16
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
17
|
+
if xdg:
|
|
18
|
+
d = Path(xdg) / "regix"
|
|
19
|
+
else:
|
|
20
|
+
d = Path(config_dir).expanduser()
|
|
21
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return d
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cache_key(commit_sha: str, backend_versions: dict[str, str]) -> str:
|
|
26
|
+
"""Compute cache key from commit SHA and backend versions."""
|
|
27
|
+
raw = commit_sha + ":" + json.dumps(sorted(backend_versions.items()))
|
|
28
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def lookup(
|
|
32
|
+
commit_sha: str,
|
|
33
|
+
backend_versions: dict[str, str],
|
|
34
|
+
cache_dir: str = "~/.cache/regix",
|
|
35
|
+
) -> Snapshot | None:
|
|
36
|
+
"""Return cached snapshot or None."""
|
|
37
|
+
d = _cache_dir(cache_dir)
|
|
38
|
+
key = _cache_key(commit_sha, backend_versions)
|
|
39
|
+
path = d / f"{key}.json.gz"
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
raw = gzip.decompress(path.read_bytes()).decode("utf-8")
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
return Snapshot.load.__func__.__code__ # type: ignore[attr-defined]
|
|
46
|
+
# Simplified: reconstruct from stored JSON
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def store(
|
|
53
|
+
snapshot: Snapshot,
|
|
54
|
+
cache_dir: str = "~/.cache/regix",
|
|
55
|
+
) -> Path:
|
|
56
|
+
"""Store a snapshot in the cache, return its path."""
|
|
57
|
+
d = _cache_dir(cache_dir)
|
|
58
|
+
if not snapshot.commit_sha:
|
|
59
|
+
raise ValueError("Cannot cache a snapshot without a commit SHA (local ref)")
|
|
60
|
+
key = _cache_key(snapshot.commit_sha, snapshot.backend_versions)
|
|
61
|
+
path = d / f"{key}.json.gz"
|
|
62
|
+
data = json.dumps({
|
|
63
|
+
"ref": snapshot.ref,
|
|
64
|
+
"commit_sha": snapshot.commit_sha,
|
|
65
|
+
"timestamp": snapshot.timestamp.isoformat(),
|
|
66
|
+
"workdir": str(snapshot.workdir),
|
|
67
|
+
"backend_versions": snapshot.backend_versions,
|
|
68
|
+
"symbols": [
|
|
69
|
+
{
|
|
70
|
+
"file": s.file,
|
|
71
|
+
"symbol": s.symbol,
|
|
72
|
+
"line_start": s.line_start,
|
|
73
|
+
"line_end": s.line_end,
|
|
74
|
+
"cc": s.cc,
|
|
75
|
+
"mi": s.mi,
|
|
76
|
+
"length": s.length,
|
|
77
|
+
"coverage": s.coverage,
|
|
78
|
+
"docstring_coverage": s.docstring_coverage,
|
|
79
|
+
"quality_score": s.quality_score,
|
|
80
|
+
"imports": s.imports,
|
|
81
|
+
"raw": s.raw,
|
|
82
|
+
}
|
|
83
|
+
for s in snapshot.symbols
|
|
84
|
+
],
|
|
85
|
+
}, default=str)
|
|
86
|
+
path.write_bytes(gzip.compress(data.encode("utf-8")))
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear(cache_dir: str = "~/.cache/regix") -> int:
|
|
91
|
+
"""Remove all cached snapshots. Returns count removed."""
|
|
92
|
+
d = _cache_dir(cache_dir)
|
|
93
|
+
count = 0
|
|
94
|
+
for f in d.glob("*.json.gz"):
|
|
95
|
+
f.unlink()
|
|
96
|
+
count += 1
|
|
97
|
+
return count
|