kragg 0.3.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.
- kragg/__init__.py +5 -0
- kragg/__main__.py +4 -0
- kragg/brief.py +127 -0
- kragg/catalog.py +456 -0
- kragg/changes.py +81 -0
- kragg/check.py +82 -0
- kragg/cli.py +150 -0
- kragg/commands.py +464 -0
- kragg/coverage.py +162 -0
- kragg/critical.py +108 -0
- kragg/environment.py +165 -0
- kragg/flaky.py +143 -0
- kragg/gates/__init__.py +1 -0
- kragg/gates/architecture.py +121 -0
- kragg/gates/critical_coverage.py +43 -0
- kragg/gates/critical_tests.py +120 -0
- kragg/gates/criticality.py +451 -0
- kragg/gates/halstead.py +80 -0
- kragg/gates/secrets.py +86 -0
- kragg/gates/test_quality.py +129 -0
- kragg/gates/type_complexity.py +171 -0
- kragg/hooks.py +168 -0
- kragg/journal.py +156 -0
- kragg/mapping.py +127 -0
- kragg/models.py +78 -0
- kragg/mutation.py +392 -0
- kragg/parsers.py +181 -0
- kragg/policy.py +105 -0
- kragg/py.typed +0 -0
- kragg/report.py +336 -0
- kragg/runner.py +26 -0
- kragg/scaffold.py +409 -0
- kragg/spec.py +177 -0
- kragg/templates.py +267 -0
- kragg-0.3.0.dist-info/METADATA +189 -0
- kragg-0.3.0.dist-info/RECORD +39 -0
- kragg-0.3.0.dist-info/WHEEL +4 -0
- kragg-0.3.0.dist-info/entry_points.txt +2 -0
- kragg-0.3.0.dist-info/licenses/LICENSE +21 -0
kragg/__init__.py
ADDED
kragg/__main__.py
ADDED
kragg/brief.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Change brief: a human-reviewable digest of the working-tree change set.
|
|
2
|
+
|
|
3
|
+
A vibe-coded change is sane when a reviewer can grasp it in minutes. The
|
|
4
|
+
brief groups changed files by area, names the critical functions touched,
|
|
5
|
+
and reports the last gate run — markdown ready for a PR description.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from kragg import journal
|
|
13
|
+
from kragg.gates.critical_tests import critical_in_files
|
|
14
|
+
from kragg.policy import KraggPolicy
|
|
15
|
+
from kragg.runner import run_command
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_brief(root: Path, policy: KraggPolicy, since: str | None) -> str | None:
|
|
19
|
+
"""Build the markdown brief; None when git is unavailable."""
|
|
20
|
+
base = _resolve_base(root, since)
|
|
21
|
+
if base is None:
|
|
22
|
+
return None
|
|
23
|
+
changed = _changed_files(root, base)
|
|
24
|
+
if changed is None:
|
|
25
|
+
return None
|
|
26
|
+
lines = ["# Change brief", ""]
|
|
27
|
+
lines.append(_stats_line(root, base, changed, since))
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.extend(_grouped_sections(changed, policy))
|
|
30
|
+
lines.extend(_critical_section(root, policy, changed))
|
|
31
|
+
lines.extend(_gate_section(root))
|
|
32
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _resolve_base(root: Path, since: str | None) -> str | None:
|
|
36
|
+
if since is None:
|
|
37
|
+
return "HEAD"
|
|
38
|
+
merge_base = _git_lines(root, "merge-base", since, "HEAD")
|
|
39
|
+
return merge_base[0].strip() if merge_base else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _changed_files(root: Path, base: str) -> list[str] | None:
|
|
43
|
+
if _git_lines(root, "rev-parse", "--is-inside-work-tree") is None:
|
|
44
|
+
return None
|
|
45
|
+
changed = _git_lines(root, "diff", "--name-only", "--diff-filter=ACMR", base)
|
|
46
|
+
untracked = _git_lines(root, "ls-files", "--others", "--exclude-standard")
|
|
47
|
+
files: list[str] = []
|
|
48
|
+
for raw in [*(changed or []), *(untracked or [])]:
|
|
49
|
+
name = raw.strip()
|
|
50
|
+
if name and not name.startswith(".kragg/") and name not in files:
|
|
51
|
+
files.append(name)
|
|
52
|
+
return files
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stats_line(
|
|
56
|
+
root: Path,
|
|
57
|
+
base: str,
|
|
58
|
+
changed: list[str],
|
|
59
|
+
since: str | None,
|
|
60
|
+
) -> str:
|
|
61
|
+
added = deleted = 0
|
|
62
|
+
for line in _git_lines(root, "diff", "--numstat", base) or []:
|
|
63
|
+
parts = line.split("\t")
|
|
64
|
+
if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit():
|
|
65
|
+
added += int(parts[0])
|
|
66
|
+
deleted += int(parts[1])
|
|
67
|
+
against = since or "HEAD"
|
|
68
|
+
return f"{len(changed)} files changed (+{added} / -{deleted}) vs {against}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _grouped_sections(changed: list[str], policy: KraggPolicy) -> list[str]:
|
|
72
|
+
groups: dict[str, list[str]] = {"Source": [], "Tests": [], "Other": []}
|
|
73
|
+
for name in changed:
|
|
74
|
+
groups[_area(name, policy)].append(name)
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
for title in ("Source", "Tests", "Other"):
|
|
77
|
+
if groups[title]:
|
|
78
|
+
lines.append(f"## {title}")
|
|
79
|
+
lines.extend(f"- {name}" for name in groups[title])
|
|
80
|
+
lines.append("")
|
|
81
|
+
return lines
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _area(name: str, policy: KraggPolicy) -> str:
|
|
85
|
+
path = Path(name)
|
|
86
|
+
if any(path.is_relative_to(prefix) for prefix in policy.source_paths):
|
|
87
|
+
return "Source"
|
|
88
|
+
if any(path.is_relative_to(prefix) for prefix in policy.test_paths):
|
|
89
|
+
return "Tests"
|
|
90
|
+
return "Other"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _critical_section(
|
|
94
|
+
root: Path,
|
|
95
|
+
policy: KraggPolicy,
|
|
96
|
+
changed: list[str],
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
python_files = [name for name in changed if name.endswith(".py")]
|
|
99
|
+
touched = critical_in_files(root, policy.source_paths, python_files)
|
|
100
|
+
lines = ["## Critical functions touched"]
|
|
101
|
+
if touched:
|
|
102
|
+
lines.extend(
|
|
103
|
+
f"- `{qualname}` (fan-in {fan_in}) in {file}"
|
|
104
|
+
for qualname, fan_in, file in touched
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
lines.append("none")
|
|
108
|
+
lines.append("")
|
|
109
|
+
return lines
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _gate_section(root: Path) -> list[str]:
|
|
113
|
+
runs = journal.read_runs(root, 10)
|
|
114
|
+
lines = ["## Last gate run"]
|
|
115
|
+
if runs:
|
|
116
|
+
lines.extend(journal.render_status_lines(runs))
|
|
117
|
+
else:
|
|
118
|
+
lines.append("no recorded runs (run `uv run kragg check`)")
|
|
119
|
+
return lines
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _git_lines(root: Path, *args: str) -> list[str] | None:
|
|
123
|
+
try:
|
|
124
|
+
result = run_command("git", ["git", *args], root)
|
|
125
|
+
except OSError:
|
|
126
|
+
return None
|
|
127
|
+
return result.stdout.splitlines() if result.passed else None
|
kragg/catalog.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Gate catalog: assembles the concrete check and security pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from kragg.check import FAST, SLOW, GateSpec, OutputParser
|
|
10
|
+
from kragg.coverage import COVERAGE_JSON_RELATIVE
|
|
11
|
+
from kragg.environment import (
|
|
12
|
+
ProjectEnvironment,
|
|
13
|
+
missing_interpreter_message,
|
|
14
|
+
missing_module,
|
|
15
|
+
remediation,
|
|
16
|
+
)
|
|
17
|
+
from kragg.gates import (
|
|
18
|
+
architecture,
|
|
19
|
+
critical_coverage,
|
|
20
|
+
critical_tests,
|
|
21
|
+
halstead,
|
|
22
|
+
secrets,
|
|
23
|
+
test_quality,
|
|
24
|
+
type_complexity,
|
|
25
|
+
)
|
|
26
|
+
from kragg.models import GateResult, Violation
|
|
27
|
+
from kragg.parsers import (
|
|
28
|
+
parse_bandit_json,
|
|
29
|
+
parse_mypy_output,
|
|
30
|
+
parse_pip_audit_json,
|
|
31
|
+
parse_pytest_output,
|
|
32
|
+
parse_radon_cc,
|
|
33
|
+
parse_radon_mi,
|
|
34
|
+
parse_ruff_json,
|
|
35
|
+
)
|
|
36
|
+
from kragg.policy import KraggPolicy
|
|
37
|
+
from kragg.runner import run_command
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_check_gates(
|
|
41
|
+
root: Path,
|
|
42
|
+
policy: KraggPolicy,
|
|
43
|
+
env: ProjectEnvironment,
|
|
44
|
+
targets: tuple[str, ...],
|
|
45
|
+
incremental: bool = False,
|
|
46
|
+
) -> list[GateSpec]:
|
|
47
|
+
"""Assemble the full `kragg check` pipeline."""
|
|
48
|
+
slow_skip = "incremental mode" if incremental else None
|
|
49
|
+
(root / ".kragg").mkdir(parents=True, exist_ok=True)
|
|
50
|
+
pytest_args = (
|
|
51
|
+
"--cov=src",
|
|
52
|
+
"--cov-report=term-missing",
|
|
53
|
+
f"--cov-report=json:{COVERAGE_JSON_RELATIVE}",
|
|
54
|
+
f"--cov-fail-under={policy.coverage_fail_under}",
|
|
55
|
+
"-q",
|
|
56
|
+
)
|
|
57
|
+
return [
|
|
58
|
+
GateSpec("ruff", FAST, lambda: _ruff_gate(root, targets)),
|
|
59
|
+
GateSpec(
|
|
60
|
+
"mypy",
|
|
61
|
+
FAST,
|
|
62
|
+
lambda: _project_tool_gate(
|
|
63
|
+
"mypy", env, root, "mypy", targets, parse_mypy_output
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
GateSpec("radon-cc", FAST, lambda: _radon_cc_gate(root, targets)),
|
|
67
|
+
GateSpec("radon-mi", FAST, lambda: _radon_mi_gate(root, targets)),
|
|
68
|
+
GateSpec("halstead", FAST, lambda: _halstead_gate(root, targets)),
|
|
69
|
+
GateSpec(
|
|
70
|
+
"type-complexity",
|
|
71
|
+
FAST,
|
|
72
|
+
lambda: _type_complexity_gate(root, policy, targets),
|
|
73
|
+
),
|
|
74
|
+
GateSpec(
|
|
75
|
+
"boundaries",
|
|
76
|
+
FAST,
|
|
77
|
+
lambda: _boundaries_gate(root, policy),
|
|
78
|
+
skip_reason=None if len(policy.layers) >= 2 else "no layers configured",
|
|
79
|
+
),
|
|
80
|
+
GateSpec("structure", FAST, lambda: _structure_gate(root, policy)),
|
|
81
|
+
GateSpec(
|
|
82
|
+
"critical-tests",
|
|
83
|
+
FAST,
|
|
84
|
+
lambda: _critical_tests_gate(root, policy),
|
|
85
|
+
skip_reason=_no_criticality_reason(root),
|
|
86
|
+
),
|
|
87
|
+
GateSpec("test-quality", FAST, lambda: _test_quality_gate(root, policy)),
|
|
88
|
+
GateSpec("bandit", FAST, lambda: _bandit_gate(root, targets)),
|
|
89
|
+
GateSpec("detect-secrets", FAST, lambda: _secrets_gate(root, targets)),
|
|
90
|
+
GateSpec(
|
|
91
|
+
"pytest-coverage",
|
|
92
|
+
SLOW,
|
|
93
|
+
lambda: _project_tool_gate(
|
|
94
|
+
"pytest-coverage",
|
|
95
|
+
env,
|
|
96
|
+
root,
|
|
97
|
+
"pytest",
|
|
98
|
+
pytest_args,
|
|
99
|
+
parse_pytest_output,
|
|
100
|
+
),
|
|
101
|
+
skip_reason=slow_skip,
|
|
102
|
+
),
|
|
103
|
+
GateSpec(
|
|
104
|
+
"critical-coverage",
|
|
105
|
+
SLOW,
|
|
106
|
+
lambda: _critical_coverage_gate(root, policy),
|
|
107
|
+
skip_reason=slow_skip or _no_criticality_reason(root),
|
|
108
|
+
),
|
|
109
|
+
GateSpec(
|
|
110
|
+
"pip-audit",
|
|
111
|
+
SLOW,
|
|
112
|
+
lambda: _project_tool_gate(
|
|
113
|
+
"pip-audit",
|
|
114
|
+
env,
|
|
115
|
+
root,
|
|
116
|
+
"pip_audit",
|
|
117
|
+
("-f", "json"),
|
|
118
|
+
parse_pip_audit_json,
|
|
119
|
+
),
|
|
120
|
+
skip_reason=slow_skip,
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _no_criticality_reason(root: Path) -> str | None:
|
|
126
|
+
if (root / ".kragg" / "criticality.json").exists():
|
|
127
|
+
return None
|
|
128
|
+
return "no criticality data (run `kragg criticality --write`)"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_security_gates(
|
|
132
|
+
root: Path,
|
|
133
|
+
env: ProjectEnvironment,
|
|
134
|
+
targets: tuple[str, ...],
|
|
135
|
+
incremental: bool = False,
|
|
136
|
+
) -> list[GateSpec]:
|
|
137
|
+
"""Assemble the `kragg security` pipeline."""
|
|
138
|
+
slow_skip = "incremental mode" if incremental else None
|
|
139
|
+
return [
|
|
140
|
+
GateSpec("bandit", FAST, lambda: _bandit_gate(root, targets)),
|
|
141
|
+
GateSpec("detect-secrets", FAST, lambda: _secrets_gate(root, targets)),
|
|
142
|
+
GateSpec(
|
|
143
|
+
"pip-audit",
|
|
144
|
+
SLOW,
|
|
145
|
+
lambda: _project_tool_gate(
|
|
146
|
+
"pip-audit",
|
|
147
|
+
env,
|
|
148
|
+
root,
|
|
149
|
+
"pip_audit",
|
|
150
|
+
("-f", "json"),
|
|
151
|
+
parse_pip_audit_json,
|
|
152
|
+
),
|
|
153
|
+
skip_reason=slow_skip,
|
|
154
|
+
),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _project_tool_gate(
|
|
159
|
+
name: str,
|
|
160
|
+
env: ProjectEnvironment,
|
|
161
|
+
root: Path,
|
|
162
|
+
module: str,
|
|
163
|
+
args: tuple[str, ...],
|
|
164
|
+
parser: OutputParser | None,
|
|
165
|
+
) -> GateResult:
|
|
166
|
+
"""Run an environment-dependent tool on the project interpreter."""
|
|
167
|
+
if not env.found:
|
|
168
|
+
return GateResult(
|
|
169
|
+
name=name,
|
|
170
|
+
passed=False,
|
|
171
|
+
error=True,
|
|
172
|
+
output=missing_interpreter_message(root),
|
|
173
|
+
)
|
|
174
|
+
command = env.module_command(module, *args)
|
|
175
|
+
result = run_command(name, command, root)
|
|
176
|
+
if not result.passed:
|
|
177
|
+
missing = missing_module(result)
|
|
178
|
+
if missing is not None and _is_tool_module(module, missing):
|
|
179
|
+
return GateResult(
|
|
180
|
+
name=name,
|
|
181
|
+
passed=False,
|
|
182
|
+
error=True,
|
|
183
|
+
command=tuple(command),
|
|
184
|
+
output=(
|
|
185
|
+
f"{module} is not installed in the project environment "
|
|
186
|
+
f"({env.describe()}).\n{remediation(missing)}"
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
violations = parser(result.stdout) if parser is not None else ()
|
|
190
|
+
return GateResult(
|
|
191
|
+
name=name,
|
|
192
|
+
passed=result.passed,
|
|
193
|
+
output="" if result.passed or violations else result.output,
|
|
194
|
+
command=tuple(command),
|
|
195
|
+
violations=violations,
|
|
196
|
+
violation_count=len(violations),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_tool_module(module: str, missing: str) -> bool:
|
|
201
|
+
"""Only the tool itself missing is an environment error.
|
|
202
|
+
|
|
203
|
+
A user-code import failing inside pytest also prints "No module named X";
|
|
204
|
+
that is a test failure, not a broken environment.
|
|
205
|
+
"""
|
|
206
|
+
return missing == module or (module == "pytest" and missing == "pytest_cov")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _command_gate(
|
|
210
|
+
name: str,
|
|
211
|
+
command: list[str],
|
|
212
|
+
root: Path,
|
|
213
|
+
parser: OutputParser,
|
|
214
|
+
error_codes: tuple[int, ...] = (),
|
|
215
|
+
) -> GateResult:
|
|
216
|
+
result = run_command(name, command, root)
|
|
217
|
+
violations = parser(result.stdout)
|
|
218
|
+
return GateResult(
|
|
219
|
+
name=name,
|
|
220
|
+
passed=result.passed,
|
|
221
|
+
output="" if result.passed or violations else result.output,
|
|
222
|
+
command=tuple(command),
|
|
223
|
+
violations=violations,
|
|
224
|
+
violation_count=len(violations),
|
|
225
|
+
error=result.returncode in error_codes,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _ruff_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
230
|
+
command = _kragg_module("ruff", "check", "--output-format", "json", *targets)
|
|
231
|
+
|
|
232
|
+
def parse(stdout: str) -> tuple[Violation, ...]:
|
|
233
|
+
return tuple(_relative_to_root(v, root) for v in parse_ruff_json(stdout))
|
|
234
|
+
|
|
235
|
+
return _command_gate("ruff", command, root, parse, error_codes=(2,))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _relative_to_root(violation: Violation, root: Path) -> Violation:
|
|
239
|
+
if violation.file is None:
|
|
240
|
+
return violation
|
|
241
|
+
path = Path(violation.file)
|
|
242
|
+
if path.is_absolute() and path.resolve().is_relative_to(root.resolve()):
|
|
243
|
+
return replace(violation, file=str(path.resolve().relative_to(root.resolve())))
|
|
244
|
+
return violation
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _radon_cc_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
248
|
+
command = _kragg_module("radon", "cc", *targets, "-s", "-n", "C")
|
|
249
|
+
result = run_command("radon-cc", command, root)
|
|
250
|
+
violations = parse_radon_cc(result.stdout)
|
|
251
|
+
passed = result.passed and not result.stdout.strip()
|
|
252
|
+
return GateResult(
|
|
253
|
+
name="radon-cc",
|
|
254
|
+
passed=passed,
|
|
255
|
+
output="" if passed or violations else result.output,
|
|
256
|
+
command=tuple(command),
|
|
257
|
+
violations=violations,
|
|
258
|
+
violation_count=len(violations),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _radon_mi_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
263
|
+
command = _kragg_module("radon", "mi", *targets, "-s")
|
|
264
|
+
result = run_command("radon-mi", command, root)
|
|
265
|
+
violations = parse_radon_mi(result.stdout)
|
|
266
|
+
passed = result.passed and not violations
|
|
267
|
+
return GateResult(
|
|
268
|
+
name="radon-mi",
|
|
269
|
+
passed=passed,
|
|
270
|
+
output="" if passed or violations else result.output,
|
|
271
|
+
command=tuple(command),
|
|
272
|
+
violations=violations,
|
|
273
|
+
violation_count=len(violations),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _halstead_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
278
|
+
violations: list[Violation] = []
|
|
279
|
+
for target in targets:
|
|
280
|
+
for failure in halstead.check_path(_resolve(root, target)):
|
|
281
|
+
location, _, function = failure.location.partition("::")
|
|
282
|
+
violations.append(
|
|
283
|
+
Violation(
|
|
284
|
+
message=(
|
|
285
|
+
f"{function or location}: {failure.metric} "
|
|
286
|
+
f"{failure.actual:.1f} exceeds max {failure.maximum:.1f}"
|
|
287
|
+
),
|
|
288
|
+
file=location,
|
|
289
|
+
code="halstead",
|
|
290
|
+
fix_hint="reduce operators/operands; split the function",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return GateResult(
|
|
294
|
+
name="halstead",
|
|
295
|
+
passed=not violations,
|
|
296
|
+
violations=tuple(violations),
|
|
297
|
+
violation_count=len(violations),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _type_complexity_gate(
|
|
302
|
+
root: Path,
|
|
303
|
+
policy: KraggPolicy,
|
|
304
|
+
targets: tuple[str, ...],
|
|
305
|
+
) -> GateResult:
|
|
306
|
+
violations: list[Violation] = []
|
|
307
|
+
for target in targets:
|
|
308
|
+
found = type_complexity.check_path(
|
|
309
|
+
_resolve(root, target),
|
|
310
|
+
policy.type_max_nesting_depth,
|
|
311
|
+
policy.type_max_length,
|
|
312
|
+
)
|
|
313
|
+
for item in found:
|
|
314
|
+
violations.append(
|
|
315
|
+
Violation(
|
|
316
|
+
message=(
|
|
317
|
+
f"{item.context}: annotation `{item.annotation_text}` "
|
|
318
|
+
f"(depth={item.depth}, length={item.length})"
|
|
319
|
+
),
|
|
320
|
+
file=str(item.file_path),
|
|
321
|
+
line=item.line or None,
|
|
322
|
+
code="type-complexity",
|
|
323
|
+
fix_hint=item.suggestion,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
return GateResult(
|
|
327
|
+
name="type-complexity",
|
|
328
|
+
passed=not violations,
|
|
329
|
+
violations=tuple(violations),
|
|
330
|
+
violation_count=len(violations),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _boundaries_gate(root: Path, policy: KraggPolicy) -> GateResult:
|
|
335
|
+
violations = architecture.check_layers(root, policy.source_paths, policy.layers)
|
|
336
|
+
return GateResult(
|
|
337
|
+
name="boundaries",
|
|
338
|
+
passed=not violations,
|
|
339
|
+
violations=violations,
|
|
340
|
+
violation_count=len(violations),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _structure_gate(root: Path, policy: KraggPolicy) -> GateResult:
|
|
345
|
+
violations = architecture.check_structure(
|
|
346
|
+
root,
|
|
347
|
+
policy.source_paths,
|
|
348
|
+
policy.max_file_lines,
|
|
349
|
+
policy.max_public_symbols,
|
|
350
|
+
)
|
|
351
|
+
return GateResult(
|
|
352
|
+
name="structure",
|
|
353
|
+
passed=not violations,
|
|
354
|
+
violations=violations,
|
|
355
|
+
violation_count=len(violations),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _critical_tests_gate(root: Path, policy: KraggPolicy) -> GateResult:
|
|
360
|
+
violations = critical_tests.check_critical_tests(
|
|
361
|
+
root,
|
|
362
|
+
policy.source_paths,
|
|
363
|
+
policy.test_paths,
|
|
364
|
+
)
|
|
365
|
+
return GateResult(
|
|
366
|
+
name="critical-tests",
|
|
367
|
+
passed=not violations,
|
|
368
|
+
violations=violations,
|
|
369
|
+
violation_count=len(violations),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _test_quality_gate(root: Path, policy: KraggPolicy) -> GateResult:
|
|
374
|
+
violations = test_quality.check_tests(root, policy.test_paths)
|
|
375
|
+
return GateResult(
|
|
376
|
+
name="test-quality",
|
|
377
|
+
passed=not violations,
|
|
378
|
+
violations=violations,
|
|
379
|
+
violation_count=len(violations),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _critical_coverage_gate(root: Path, policy: KraggPolicy) -> GateResult:
|
|
384
|
+
violations = critical_coverage.check_critical_coverage(root, policy.source_paths)
|
|
385
|
+
return GateResult(
|
|
386
|
+
name="critical-coverage",
|
|
387
|
+
passed=not violations,
|
|
388
|
+
violations=violations,
|
|
389
|
+
violation_count=len(violations),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _bandit_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
394
|
+
args = ["-ll", "-r", *targets, "-f", "json"]
|
|
395
|
+
if (root / "pyproject.toml").exists():
|
|
396
|
+
args = ["-c", "pyproject.toml", *args]
|
|
397
|
+
command = _kragg_module("bandit", *args)
|
|
398
|
+
return _command_gate("bandit", command, root, parse_bandit_json, error_codes=(2,))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _secrets_gate(root: Path, targets: tuple[str, ...]) -> GateResult:
|
|
402
|
+
try:
|
|
403
|
+
baseline = secrets.load_baseline(root)
|
|
404
|
+
violations = _scan_secrets(root, targets, baseline)
|
|
405
|
+
except RuntimeError as exc:
|
|
406
|
+
return GateResult(
|
|
407
|
+
name="detect-secrets",
|
|
408
|
+
passed=False,
|
|
409
|
+
error=True,
|
|
410
|
+
output=str(exc),
|
|
411
|
+
)
|
|
412
|
+
return GateResult(
|
|
413
|
+
name="detect-secrets",
|
|
414
|
+
passed=not violations,
|
|
415
|
+
violations=violations,
|
|
416
|
+
violation_count=len(violations),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _scan_secrets(
|
|
421
|
+
root: Path,
|
|
422
|
+
targets: tuple[str, ...],
|
|
423
|
+
baseline: dict[str, list[str]],
|
|
424
|
+
) -> tuple[Violation, ...]:
|
|
425
|
+
violations: list[Violation] = []
|
|
426
|
+
for target in targets:
|
|
427
|
+
scan = secrets.scan_target(root, _resolve(root, target))
|
|
428
|
+
for filepath, findings in scan.items():
|
|
429
|
+
known = set(baseline.get(filepath, []))
|
|
430
|
+
for finding in findings:
|
|
431
|
+
hashed = finding.get("hashed_secret")
|
|
432
|
+
if not hashed or hashed in known:
|
|
433
|
+
continue
|
|
434
|
+
raw_line = finding.get("line_number")
|
|
435
|
+
violations.append(
|
|
436
|
+
Violation(
|
|
437
|
+
message=f"potential secret: {finding.get('type', 'unknown')}",
|
|
438
|
+
file=filepath,
|
|
439
|
+
line=raw_line if isinstance(raw_line, int) else None,
|
|
440
|
+
code="secret",
|
|
441
|
+
fix_hint=(
|
|
442
|
+
"remove the credential; if reviewed and safe, add it "
|
|
443
|
+
"to .secrets.baseline"
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return tuple(violations)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _kragg_module(module: str, *args: str) -> list[str]:
|
|
451
|
+
return [sys.executable, "-m", module, *args]
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _resolve(root: Path, target: str) -> Path:
|
|
455
|
+
path = Path(target)
|
|
456
|
+
return path if path.is_absolute() else root / path
|
kragg/changes.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Changed-file detection for incremental checks (`kragg check --changed`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from kragg.runner import run_command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def changed_python_files(
|
|
12
|
+
root: Path,
|
|
13
|
+
since: str | None,
|
|
14
|
+
allowed: Sequence[str],
|
|
15
|
+
) -> list[str] | None:
|
|
16
|
+
"""Return changed + untracked Python files under the allowed paths.
|
|
17
|
+
|
|
18
|
+
Returns None when git is unavailable or the root is not a repository.
|
|
19
|
+
"""
|
|
20
|
+
if not _is_git_repository(root):
|
|
21
|
+
return None
|
|
22
|
+
base = _resolve_base(root, since)
|
|
23
|
+
if base is None:
|
|
24
|
+
return None
|
|
25
|
+
changed = _git(root, "diff", "--name-only", "--diff-filter=ACMR", base) or ""
|
|
26
|
+
untracked = _git(root, "ls-files", "--others", "--exclude-standard") or ""
|
|
27
|
+
names = [*changed.splitlines(), *untracked.splitlines()]
|
|
28
|
+
return _filter_python_files(root, names, allowed)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def git_sha(root: Path) -> str | None:
|
|
32
|
+
"""Short HEAD sha, or None outside a git repository."""
|
|
33
|
+
output = _git(root, "rev-parse", "--short", "HEAD")
|
|
34
|
+
return output.strip() if output is not None else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git_dirty(root: Path) -> bool:
|
|
38
|
+
"""Whether the working tree has uncommitted changes (False off a repo)."""
|
|
39
|
+
output = _git(root, "status", "--porcelain")
|
|
40
|
+
return bool(output.strip()) if output is not None else False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_base(root: Path, since: str | None) -> str | None:
|
|
44
|
+
if since is None:
|
|
45
|
+
return "HEAD"
|
|
46
|
+
merge_base = _git(root, "merge-base", since, "HEAD")
|
|
47
|
+
return merge_base.strip() if merge_base is not None else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _filter_python_files(
|
|
51
|
+
root: Path,
|
|
52
|
+
names: list[str],
|
|
53
|
+
allowed: Sequence[str],
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
files: list[str] = []
|
|
56
|
+
for raw in names:
|
|
57
|
+
name = raw.strip()
|
|
58
|
+
if not name.endswith(".py") or name in files:
|
|
59
|
+
continue
|
|
60
|
+
if _is_allowed(name, allowed) and (root / name).exists():
|
|
61
|
+
files.append(name)
|
|
62
|
+
return files
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_git_repository(root: Path) -> bool:
|
|
66
|
+
return _git(root, "rev-parse", "--is-inside-work-tree") is not None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
70
|
+
try:
|
|
71
|
+
result = run_command("git", ["git", *args], root)
|
|
72
|
+
except OSError:
|
|
73
|
+
return None
|
|
74
|
+
return result.stdout if result.passed else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_allowed(name: str, allowed: Sequence[str]) -> bool:
|
|
78
|
+
path = Path(name)
|
|
79
|
+
return any(
|
|
80
|
+
path == Path(prefix) or path.is_relative_to(prefix) for prefix in allowed
|
|
81
|
+
)
|