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 ADDED
@@ -0,0 +1,5 @@
1
+ """Opinionated guardrails for AI-assisted Python projects."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ __all__ = ["__version__"]
kragg/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from kragg.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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
+ )