@jesdi/skills 0.0.9 → 0.0.11
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.
- package/package.json +1 -1
- package/skills/crap-gate/SKILL.md +167 -0
- package/skills/crap-gate/crap.py +210 -0
- package/skills/crap-gate/crap_config.py +124 -0
- package/skills/crap-gate/crap_coverage.py +85 -0
- package/skills/crap-gate/crap_git.py +73 -0
- package/skills/crap-gate/crap_model.py +33 -0
- package/skills/crap-gate/crap_python.py +69 -0
- package/skills/crap-gate/crap_report.py +99 -0
- package/skills/crap-gate/crap_score.py +65 -0
- package/skills/crap-gate/crap_typescript.mjs +110 -0
- package/skills/crap-gate/crap_typescript.py +30 -0
- package/skills/crap-gate/test_cli.py +152 -0
- package/skills/crap-gate/test_config.py +94 -0
- package/skills/crap-gate/test_coverage.py +88 -0
- package/skills/crap-gate/test_git.py +71 -0
- package/skills/crap-gate/test_python.py +94 -0
- package/skills/crap-gate/test_score.py +49 -0
- package/skills/crap-gate/test_typescript.py +73 -0
- package/skills-manifest.json +7 -1
package/package.json
CHANGED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crap-gate
|
|
3
|
+
description: >-
|
|
4
|
+
Score every function a branch changed with CRAP (cyclomatic complexity ×
|
|
5
|
+
untested fraction), fail on existing functions > 15 and new functions > 9,
|
|
6
|
+
and drive a fixer + independent reviewer loop until the gate passes. Use
|
|
7
|
+
when implementation is done and before pushing, when the user says "run the
|
|
8
|
+
crap gate", "check CRAP", "is this change tested enough", or when a push
|
|
9
|
+
gate reports CRAP violations.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# crap-gate — untested complexity is the thing you may not add
|
|
13
|
+
|
|
14
|
+
`CRAP(f) = CC(f)² · (1 − cov(f))³ + CC(f)`. One number per function: how much
|
|
15
|
+
complexity did this change add that tests do not verify. A CC-2 helper passes
|
|
16
|
+
untested; a CC-9 function needs 100 % coverage; a CC-40 function fails even
|
|
17
|
+
when fully tested (CRAP ≥ CC always). Thresholds come from the project's
|
|
18
|
+
`.crap-gate.json` (defaults: existing changed function > 15 fails, new
|
|
19
|
+
function > 9 fails). There are no pragmas and no allow-lists — the only bypass
|
|
20
|
+
is the human approval gate at push time.
|
|
21
|
+
|
|
22
|
+
## Running the engine
|
|
23
|
+
|
|
24
|
+
The engine ships with this skill and is stdlib-only Python (plus `node` and
|
|
25
|
+
the project's own `typescript` package for TS/TSX targets). Run it from
|
|
26
|
+
anywhere inside the repo — it walks up to `.crap-gate.json`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 <skill-dir>/crap.py # human table, exit 1 on violations
|
|
30
|
+
python3 <skill-dir>/crap.py --json # same data for a subagent prompt
|
|
31
|
+
python3 <skill-dir>/crap.py --no-run # reuse the last coverage reports
|
|
32
|
+
python3 <skill-dir>/crap.py --all # whole-repo baseline (exploration only)
|
|
33
|
+
python3 <skill-dir>/crap.py --base main --threshold-new 7 # local what-ifs
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`<skill-dir>` is wherever this skill is installed (`.agents/skills/crap-gate`,
|
|
37
|
+
`~/.my-skills/crap-gate`, …). Projects usually
|
|
38
|
+
wrap it (`make crap`); prefer the wrapper when one exists.
|
|
39
|
+
|
|
40
|
+
By default the engine runs each target's coverage command only when that
|
|
41
|
+
target has changed files, so a backend-only change never runs the frontend
|
|
42
|
+
suite. Coverage command output goes to `.crap/<target>.log`.
|
|
43
|
+
|
|
44
|
+
Reading a line:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
FAIL backend/routers/stock.py:120 get_pe_history CC 22 cov 0% CRAP 506.0 limit 15 (existing) → split to CC ≤ 3
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The hint is the cheapest way out: `cover ≥ N%` when tests alone can fix it,
|
|
51
|
+
`split to CC ≤ k` when the function is too complex to pass at its current
|
|
52
|
+
coverage, both when both work. `Not measured:` lists changed code files that
|
|
53
|
+
no target covers (never a failure, never silent).
|
|
54
|
+
|
|
55
|
+
## Project setup (once per repository)
|
|
56
|
+
|
|
57
|
+
Create `.crap-gate.json` at the repo root:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"thresholds": { "existing": 15, "new": 9 },
|
|
62
|
+
"base": "origin/main",
|
|
63
|
+
"targets": [
|
|
64
|
+
{
|
|
65
|
+
"name": "backend",
|
|
66
|
+
"paths": ["backend/**/*.py"],
|
|
67
|
+
"exclude": ["backend/tests/**"],
|
|
68
|
+
"complexity": { "tool": "python" },
|
|
69
|
+
"coverage": {
|
|
70
|
+
"format": "coverage.py",
|
|
71
|
+
"root": "backend",
|
|
72
|
+
"report": "backend/.crap/coverage.json",
|
|
73
|
+
"command": "cd backend && pytest --cov=src --cov-report=json:.crap/coverage.json -q"
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "frontend",
|
|
78
|
+
"paths": ["frontend/src/**/*.ts", "frontend/src/**/*.tsx"],
|
|
79
|
+
"exclude": ["frontend/src/**/*.test.*"],
|
|
80
|
+
"complexity": { "tool": "typescript", "cwd": "frontend" },
|
|
81
|
+
"coverage": {
|
|
82
|
+
"format": "istanbul",
|
|
83
|
+
"report": "frontend/coverage/coverage-final.json",
|
|
84
|
+
"command": "cd frontend && vitest run --coverage --coverage.reportOnFailure=true"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `thresholds` / `base` are optional (defaults shown). Flags override them.
|
|
92
|
+
- `complexity.tool`: `python` (stdlib `ast`) or `typescript` (compiler API,
|
|
93
|
+
`cwd` = directory whose `node_modules/typescript` to use).
|
|
94
|
+
- `coverage.format`: `coverage.py` (`root` = directory its file paths are
|
|
95
|
+
relative to, i.e. where pytest ran; run with `branch = true`) or `istanbul`
|
|
96
|
+
(absolute paths, e.g. vitest/jest `json` reporter).
|
|
97
|
+
- `coverage.command` runs via `sh -c` from the repo root. It must (re)write
|
|
98
|
+
`coverage.report`. If it exits non-zero but still wrote a report (failing
|
|
99
|
+
tests), the engine warns and continues; no report → exit 2.
|
|
100
|
+
|
|
101
|
+
## What counts as a function
|
|
102
|
+
|
|
103
|
+
- Named functions: `def`s and function declarations, class methods
|
|
104
|
+
(`Class.method`), nested named functions (`outer.inner`), and
|
|
105
|
+
functions/arrows that directly initialise a variable or `export default`.
|
|
106
|
+
At module level, `export const X = memo(forwardRef(() => …))` is `X`.
|
|
107
|
+
- Anonymous callbacks (`.map(() => …)`, `useMemo(() => …)`, handlers,
|
|
108
|
+
lambdas) **fold into the nearest named function**: a React component's
|
|
109
|
+
render callbacks are its complexity. The remedy is to extract a named
|
|
110
|
+
subcomponent, hook or helper — which is then scored on its own.
|
|
111
|
+
- CC counts `if`/`elif`, loops, `except`/`catch`, ternaries, `case`, each
|
|
112
|
+
`&&`/`||`/`??` (and their assignment forms), comprehension `for`/`if`.
|
|
113
|
+
Optional chaining is not counted.
|
|
114
|
+
- A function is **new** when its qualified name did not exist in the base
|
|
115
|
+
version of the file (renames count as new). Everything else that intersects
|
|
116
|
+
a diff hunk is **existing**. Untouched functions are not reported.
|
|
117
|
+
|
|
118
|
+
## The loop — when the gate fails
|
|
119
|
+
|
|
120
|
+
You are the orchestrator. Do not fix violations yourself in the same context
|
|
121
|
+
that wrote the code; a fresh pair of eyes per role is the point.
|
|
122
|
+
|
|
123
|
+
1. Run the engine with `--json` and keep the output.
|
|
124
|
+
2. **Fixer.** Dispatch a subagent with: the JSON findings, the spec of the
|
|
125
|
+
change being made (the user's intent), and these rules:
|
|
126
|
+
- Tests first. Add or extend tests that prove the function's observable
|
|
127
|
+
behaviour through its public interface. Tests that merely assert the
|
|
128
|
+
implementation's text, tokens or incidental snapshots are not evidence.
|
|
129
|
+
- Refactor only when the hint says `split to CC ≤ k` and coverage alone
|
|
130
|
+
cannot reach the limit (CC > limit), or when the function is genuinely
|
|
131
|
+
doing two things. Extract named functions; keep behaviour identical.
|
|
132
|
+
- Touch nothing outside the listed functions and their tests. No drive-by
|
|
133
|
+
cleanups, no unrelated refactors.
|
|
134
|
+
- Re-run the engine before finishing and report the before/after lines.
|
|
135
|
+
3. **Reviewer.** Dispatch a *different* subagent with the fixer's diff and the
|
|
136
|
+
original findings. It answers three questions with evidence: Do the new
|
|
137
|
+
tests exercise behaviour rather than restate the code? Is every refactor
|
|
138
|
+
behaviour-preserving (same inputs → same outputs, same side effects)? Did
|
|
139
|
+
the change stay inside the listed functions? Verdict: approve, or request
|
|
140
|
+
changes with specifics.
|
|
141
|
+
4. **Adjudicate.** If changes are requested, send the review to the fixer
|
|
142
|
+
(same subagent if it still has context, otherwise a new one with the
|
|
143
|
+
review attached) and go back to step 3. If approved, re-run the engine.
|
|
144
|
+
5. Stop after **3 rounds** with violations remaining. Report to the user: the
|
|
145
|
+
residual findings, what the fixer tried, what the reviewer objected to, and
|
|
146
|
+
your recommendation (usually: the function needs a design change, not more
|
|
147
|
+
tests). Do not loosen thresholds and do not add suppressions.
|
|
148
|
+
|
|
149
|
+
Exit 0 from the engine ends the loop. Only then push.
|
|
150
|
+
|
|
151
|
+
## Push gate
|
|
152
|
+
|
|
153
|
+
Projects that gate pushes with `no-mistakes` run the same engine as the lint
|
|
154
|
+
command with auto-fix disabled, so a violation that reaches the push parks for
|
|
155
|
+
a human decision instead of being patched by an unreviewed fix agent:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
# .no-mistakes.yaml (must be on the default branch)
|
|
159
|
+
allow_repo_commands: true
|
|
160
|
+
commands:
|
|
161
|
+
lint: "make crap-gate"
|
|
162
|
+
auto_fix:
|
|
163
|
+
lint: 0
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If a push is parked on a CRAP finding, run the loop above on the branch, then
|
|
167
|
+
`no-mistakes rerun`.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""crap-gate engine: CRAP score for every function a branch changed.
|
|
3
|
+
|
|
4
|
+
python3 crap.py [--config PATH] [--base REF] [--threshold-existing N]
|
|
5
|
+
[--threshold-new N] [--run | --no-run] [--all] [--json]
|
|
6
|
+
|
|
7
|
+
Exit 0 = no violations, 1 = violations, 2 = configuration/tool error.
|
|
8
|
+
Stdlib only; the typescript target additionally needs `node` and the
|
|
9
|
+
project's own `typescript` package.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import crap_git
|
|
21
|
+
import crap_python
|
|
22
|
+
import crap_typescript
|
|
23
|
+
from crap_config import CODE_SUFFIXES, CONFIG_NAME, Config, Target, find_config, load, matches
|
|
24
|
+
from crap_coverage import Report, load_report
|
|
25
|
+
from crap_model import Func, ToolError
|
|
26
|
+
from crap_report import Result, TargetRun, render_json, render_text
|
|
27
|
+
from crap_score import score
|
|
28
|
+
|
|
29
|
+
BASE_LABEL = "@base"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── analysis ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def _analyze(target: Target, root: Path, items: list[tuple[str, str | None]]) -> dict[str, list[Func]]:
|
|
35
|
+
"""items: (label, source_text_or_None-for-working-tree). Returns label → funcs."""
|
|
36
|
+
if target.complexity_tool == "python":
|
|
37
|
+
out: dict[str, list[Func]] = {}
|
|
38
|
+
for label, text in items:
|
|
39
|
+
source = text if text is not None else (root / label.removesuffix(BASE_LABEL)).read_text()
|
|
40
|
+
out[label] = crap_python.functions(source, label)
|
|
41
|
+
return out
|
|
42
|
+
with tempfile.TemporaryDirectory(prefix="crap-gate-") as tmp:
|
|
43
|
+
files: list[tuple[Path, str]] = []
|
|
44
|
+
for index, (label, text) in enumerate(items):
|
|
45
|
+
if text is None:
|
|
46
|
+
files.append((root / label, label))
|
|
47
|
+
else:
|
|
48
|
+
real = label.removesuffix(BASE_LABEL)
|
|
49
|
+
p = Path(tmp) / f"{index}{Path(real).suffix}"
|
|
50
|
+
p.write_text(text)
|
|
51
|
+
files.append((p, label))
|
|
52
|
+
return crap_typescript.functions(files, root / target.complexity_cwd)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _intersects(func: Func, ranges: list[tuple[int, int]]) -> bool:
|
|
56
|
+
return any(func.start <= hi and lo <= func.end for lo, hi in ranges)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _score_target(
|
|
60
|
+
target: Target,
|
|
61
|
+
files: list[tuple[str, str | None]],
|
|
62
|
+
cfg: Config,
|
|
63
|
+
base: str,
|
|
64
|
+
report: Report,
|
|
65
|
+
all_functions: bool,
|
|
66
|
+
) -> list:
|
|
67
|
+
items: list[tuple[str, str | None]] = []
|
|
68
|
+
base_sources: dict[str, str | None] = {}
|
|
69
|
+
for path, old in files:
|
|
70
|
+
items.append((path, None))
|
|
71
|
+
if not all_functions and old is not None:
|
|
72
|
+
base_sources[path] = crap_git.show(cfg.root, base, old)
|
|
73
|
+
if base_sources[path] is not None:
|
|
74
|
+
items.append((path + BASE_LABEL, base_sources[path]))
|
|
75
|
+
analysed = _analyze(target, cfg.root, items)
|
|
76
|
+
|
|
77
|
+
scored = []
|
|
78
|
+
for path, old in files:
|
|
79
|
+
funcs = analysed[path]
|
|
80
|
+
if all_functions:
|
|
81
|
+
touched, base_names = None, {f.name for f in funcs}
|
|
82
|
+
elif old is None or base_sources.get(path) is None:
|
|
83
|
+
touched, base_names = None, set()
|
|
84
|
+
else:
|
|
85
|
+
touched = crap_git.touched_lines(cfg.root, base, path)
|
|
86
|
+
base_names = {f.name for f in analysed[path + BASE_LABEL]}
|
|
87
|
+
for func in funcs:
|
|
88
|
+
if touched is not None and not _intersects(func, touched):
|
|
89
|
+
continue
|
|
90
|
+
kind = "existing" if func.name in base_names else "new"
|
|
91
|
+
children = [g for g in funcs if func.contains(g)]
|
|
92
|
+
if report.has_file(path):
|
|
93
|
+
coverage, note = report.function_coverage(func, children), ""
|
|
94
|
+
else:
|
|
95
|
+
coverage, note = 0.0, "file absent from coverage report"
|
|
96
|
+
scored.append(score(target.name, func, coverage, kind, cfg.thresholds, note))
|
|
97
|
+
return scored
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── coverage commands ───────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _maybe_run_coverage(target: Target, root: Path, run_mode: str, needed: bool) -> TargetRun:
|
|
103
|
+
report = root / target.coverage_report
|
|
104
|
+
should_run = run_mode == "run" or (run_mode == "auto" and needed)
|
|
105
|
+
if not should_run:
|
|
106
|
+
if not report.exists():
|
|
107
|
+
raise ToolError(f"{target.name}: report {target.coverage_report} is missing; run without --no-run")
|
|
108
|
+
return TargetRun(target.name, target.coverage_report, ran=False, seconds=0.0)
|
|
109
|
+
if not target.coverage_command:
|
|
110
|
+
raise ToolError(f"{target.name}: coverage.command is not configured")
|
|
111
|
+
log = root / ".crap" / f"{target.name}.log"
|
|
112
|
+
log.parent.mkdir(exist_ok=True)
|
|
113
|
+
before = report.stat().st_mtime_ns if report.exists() else None
|
|
114
|
+
started = time.monotonic()
|
|
115
|
+
# shell=True is deliberate: the command is the repository's own committed
|
|
116
|
+
# config (same trust model as a Makefile or no-mistakes' commands.*), never
|
|
117
|
+
# user input — it needs `cd … && …`, env assignments and pipes.
|
|
118
|
+
with log.open("w") as fh:
|
|
119
|
+
proc = subprocess.run(target.coverage_command, shell=True, cwd=root, stdout=fh, stderr=subprocess.STDOUT)
|
|
120
|
+
seconds = time.monotonic() - started
|
|
121
|
+
fresh = report.exists() and (before is None or report.stat().st_mtime_ns > before)
|
|
122
|
+
if not fresh:
|
|
123
|
+
raise ToolError(
|
|
124
|
+
f"{target.name}: coverage command exited {proc.returncode} and wrote no report "
|
|
125
|
+
f"({target.coverage_report}); see {log.relative_to(root)}"
|
|
126
|
+
)
|
|
127
|
+
if proc.returncode != 0:
|
|
128
|
+
print(
|
|
129
|
+
f"crap: {target.name}: coverage command exited {proc.returncode} (failing tests?) — "
|
|
130
|
+
f"using the report it wrote; see {log.relative_to(root)}",
|
|
131
|
+
file=sys.stderr,
|
|
132
|
+
)
|
|
133
|
+
return TargetRun(target.name, target.coverage_report, ran=True, seconds=seconds)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── engine ──────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def run(cfg: Config, run_mode: str = "auto", all_functions: bool = False) -> Result:
|
|
139
|
+
base = crap_git.merge_base(cfg.root, cfg.base)
|
|
140
|
+
if all_functions:
|
|
141
|
+
candidates: list[tuple[str, str | None]] = [(p, p) for p in crap_git.tracked_files(cfg.root)]
|
|
142
|
+
else:
|
|
143
|
+
candidates = crap_git.changed_files(cfg.root, base)
|
|
144
|
+
|
|
145
|
+
per_target: dict[str, list[tuple[str, str | None]]] = {t.name: [] for t in cfg.targets}
|
|
146
|
+
not_measured: list[str] = []
|
|
147
|
+
for path, old in candidates:
|
|
148
|
+
if Path(path).suffix not in CODE_SUFFIXES:
|
|
149
|
+
continue
|
|
150
|
+
owner = next((t for t in cfg.targets if matches(t, path)), None)
|
|
151
|
+
if owner is None:
|
|
152
|
+
if not all_functions:
|
|
153
|
+
not_measured.append(path)
|
|
154
|
+
continue
|
|
155
|
+
per_target[owner.name].append((path, old))
|
|
156
|
+
|
|
157
|
+
runs: list[TargetRun] = []
|
|
158
|
+
functions = []
|
|
159
|
+
for target in cfg.targets:
|
|
160
|
+
files = per_target[target.name]
|
|
161
|
+
if not files:
|
|
162
|
+
continue
|
|
163
|
+
runs.append(_maybe_run_coverage(target, cfg.root, run_mode, needed=True))
|
|
164
|
+
report = load_report(target, cfg.root)
|
|
165
|
+
functions.extend(_score_target(target, files, cfg, base, report, all_functions))
|
|
166
|
+
|
|
167
|
+
return Result(base, cfg.base, cfg.thresholds, runs, functions, not_measured)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def _parser() -> argparse.ArgumentParser:
|
|
173
|
+
p = argparse.ArgumentParser(prog="crap", description="CRAP score for every function a branch changed.")
|
|
174
|
+
p.add_argument("--config", type=Path, help="path to .crap-gate.json (default: walk up from cwd)")
|
|
175
|
+
p.add_argument("--base", help="git ref to diff against (default from config, else origin/main)")
|
|
176
|
+
p.add_argument("--threshold-existing", type=float)
|
|
177
|
+
p.add_argument("--threshold-new", type=float)
|
|
178
|
+
mode = p.add_mutually_exclusive_group()
|
|
179
|
+
mode.add_argument("--run", dest="run_mode", action="store_const", const="run", help="always run coverage commands")
|
|
180
|
+
mode.add_argument("--no-run", dest="run_mode", action="store_const", const="no-run", help="reuse existing reports")
|
|
181
|
+
p.set_defaults(run_mode="auto")
|
|
182
|
+
p.add_argument("--all", action="store_true", help="score every function in scope, not only changed ones")
|
|
183
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
184
|
+
return p
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv: list[str] | None = None) -> int:
|
|
188
|
+
args = _parser().parse_args(argv)
|
|
189
|
+
try:
|
|
190
|
+
config_path = args.config.resolve() if args.config else find_config(Path.cwd())
|
|
191
|
+
cfg = load(
|
|
192
|
+
config_path,
|
|
193
|
+
{"base": args.base, "threshold_existing": args.threshold_existing, "threshold_new": args.threshold_new},
|
|
194
|
+
)
|
|
195
|
+
toplevel = crap_git.toplevel(cfg.root).resolve()
|
|
196
|
+
if toplevel != cfg.root:
|
|
197
|
+
raise ToolError(
|
|
198
|
+
f"{CONFIG_NAME} must live at the git repository root; it is in "
|
|
199
|
+
f"{cfg.root}, but the root is {toplevel}"
|
|
200
|
+
)
|
|
201
|
+
result = run(cfg, run_mode=args.run_mode, all_functions=args.all)
|
|
202
|
+
except ToolError as exc:
|
|
203
|
+
print(f"crap: {exc}", file=sys.stderr)
|
|
204
|
+
return 2
|
|
205
|
+
sys.stdout.write(render_json(result) if args.json else render_text(result))
|
|
206
|
+
return 1 if result.summary["fail"] else 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Discover, validate and expose .crap-gate.json. Stdlib only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from crap_model import ToolError
|
|
10
|
+
|
|
11
|
+
CONFIG_NAME = ".crap-gate.json"
|
|
12
|
+
CODE_SUFFIXES = {".py", ".ts", ".tsx"}
|
|
13
|
+
COMPLEXITY_TOOLS = {"python", "typescript"}
|
|
14
|
+
COVERAGE_FORMATS = {"coverage.py", "istanbul"}
|
|
15
|
+
DEFAULT_THRESHOLDS = {"existing": 15, "new": 9}
|
|
16
|
+
DEFAULT_BASE = "origin/main"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Target:
|
|
21
|
+
name: str
|
|
22
|
+
paths: list[str]
|
|
23
|
+
exclude: list[str]
|
|
24
|
+
complexity_tool: str
|
|
25
|
+
complexity_cwd: str
|
|
26
|
+
coverage_format: str
|
|
27
|
+
coverage_report: str
|
|
28
|
+
coverage_command: str
|
|
29
|
+
coverage_root: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Config:
|
|
34
|
+
root: Path
|
|
35
|
+
thresholds: dict[str, float]
|
|
36
|
+
base: str
|
|
37
|
+
targets: list[Target]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_config(start: Path) -> Path:
|
|
41
|
+
for directory in [start.resolve(), *start.resolve().parents]:
|
|
42
|
+
candidate = directory / CONFIG_NAME
|
|
43
|
+
if candidate.is_file():
|
|
44
|
+
return candidate
|
|
45
|
+
raise ToolError(f"no {CONFIG_NAME} found in {start} or any parent directory")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load(path: Path, overrides: dict) -> Config:
|
|
49
|
+
try:
|
|
50
|
+
raw = json.loads(path.read_text())
|
|
51
|
+
except (OSError, ValueError) as exc:
|
|
52
|
+
raise ToolError(f"cannot read {path}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
thresholds = dict(DEFAULT_THRESHOLDS)
|
|
55
|
+
thresholds.update(raw.get("thresholds", {}))
|
|
56
|
+
if overrides.get("threshold_existing") is not None:
|
|
57
|
+
thresholds["existing"] = overrides["threshold_existing"]
|
|
58
|
+
if overrides.get("threshold_new") is not None:
|
|
59
|
+
thresholds["new"] = overrides["threshold_new"]
|
|
60
|
+
for key in ("existing", "new"):
|
|
61
|
+
if not isinstance(thresholds.get(key), (int, float)):
|
|
62
|
+
raise ToolError(f"thresholds.{key} must be a number")
|
|
63
|
+
|
|
64
|
+
base = overrides.get("base") or raw.get("base") or DEFAULT_BASE
|
|
65
|
+
|
|
66
|
+
targets = [_target(entry, index) for index, entry in enumerate(raw.get("targets", []))]
|
|
67
|
+
if not targets:
|
|
68
|
+
raise ToolError("config needs at least one target")
|
|
69
|
+
names = [t.name for t in targets]
|
|
70
|
+
if len(set(names)) != len(names):
|
|
71
|
+
raise ToolError(f"duplicate target name in {names}")
|
|
72
|
+
|
|
73
|
+
return Config(root=path.parent.resolve(), thresholds=thresholds, base=base, targets=targets)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _target(entry: dict, index: int) -> Target:
|
|
77
|
+
name = entry.get("name") or f"target[{index}]"
|
|
78
|
+
complexity = entry.get("complexity", {})
|
|
79
|
+
coverage = entry.get("coverage", {})
|
|
80
|
+
tool = complexity.get("tool")
|
|
81
|
+
if tool not in COMPLEXITY_TOOLS:
|
|
82
|
+
raise ToolError(f"{name}: complexity.tool must be one of {sorted(COMPLEXITY_TOOLS)}, got {tool!r}")
|
|
83
|
+
fmt = coverage.get("format")
|
|
84
|
+
if fmt not in COVERAGE_FORMATS:
|
|
85
|
+
raise ToolError(f"{name}: coverage.format must be one of {sorted(COVERAGE_FORMATS)}, got {fmt!r}")
|
|
86
|
+
if not coverage.get("report"):
|
|
87
|
+
raise ToolError(f"{name}: coverage.report is required")
|
|
88
|
+
if not entry.get("paths"):
|
|
89
|
+
raise ToolError(f"{name}: paths must list at least one glob")
|
|
90
|
+
return Target(
|
|
91
|
+
name=name,
|
|
92
|
+
paths=list(entry["paths"]),
|
|
93
|
+
exclude=list(entry.get("exclude", [])),
|
|
94
|
+
complexity_tool=tool,
|
|
95
|
+
complexity_cwd=complexity.get("cwd", "."),
|
|
96
|
+
coverage_format=fmt,
|
|
97
|
+
coverage_report=coverage["report"],
|
|
98
|
+
coverage_command=coverage.get("command", ""),
|
|
99
|
+
coverage_root=coverage.get("root", "."),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _glob_re(pattern: str) -> re.Pattern[str]:
|
|
104
|
+
"""Translate a path glob to a regex: `**/` spans directories, `*` and `?`
|
|
105
|
+
never cross `/` (same rules as no-mistakes' ignore_patterns)."""
|
|
106
|
+
out, i = "", 0
|
|
107
|
+
while i < len(pattern):
|
|
108
|
+
if pattern.startswith("**/", i):
|
|
109
|
+
out, i = out + "(?:.*/)?", i + 3
|
|
110
|
+
elif pattern.startswith("**", i):
|
|
111
|
+
out, i = out + ".*", i + 2
|
|
112
|
+
elif pattern[i] == "*":
|
|
113
|
+
out, i = out + "[^/]*", i + 1
|
|
114
|
+
elif pattern[i] == "?":
|
|
115
|
+
out, i = out + "[^/]", i + 1
|
|
116
|
+
else:
|
|
117
|
+
out, i = out + re.escape(pattern[i]), i + 1
|
|
118
|
+
return re.compile(f"^{out}$")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def matches(target: Target, file: str) -> bool:
|
|
122
|
+
if any(_glob_re(p).match(file) for p in target.exclude):
|
|
123
|
+
return False
|
|
124
|
+
return any(_glob_re(p).match(file) for p in target.paths)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Per-function coverage from coverage.py JSON and istanbul JSON. Stdlib only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from crap_config import Target
|
|
9
|
+
from crap_model import Func, ToolError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Report(Protocol):
|
|
13
|
+
def has_file(self, file: str) -> bool: ...
|
|
14
|
+
def function_coverage(self, func: Func, children: list[Func]) -> float: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CoveragePyReport:
|
|
18
|
+
"""coverage.py `json` report (7.x): files[path].functions[qualname] with
|
|
19
|
+
start_line and summary.percent_covered (statements + branches)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, data: dict, root: str) -> None:
|
|
22
|
+
prefix = "" if root in ("", ".") else root.rstrip("/") + "/"
|
|
23
|
+
self._files = {prefix + path: entry for path, entry in data.get("files", {}).items()}
|
|
24
|
+
|
|
25
|
+
def has_file(self, file: str) -> bool:
|
|
26
|
+
return file in self._files
|
|
27
|
+
|
|
28
|
+
def function_coverage(self, func: Func, children: list[Func]) -> float:
|
|
29
|
+
entries = [e for e in self._files[func.file].get("functions", {}).values() if "start_line" in e]
|
|
30
|
+
exact = [e for e in entries if e["start_line"] == func.start]
|
|
31
|
+
inside = sorted((e for e in entries if func.start <= e["start_line"] <= func.end), key=lambda e: e["start_line"])
|
|
32
|
+
chosen = exact or inside
|
|
33
|
+
if not chosen:
|
|
34
|
+
return 0.0
|
|
35
|
+
return float(chosen[0]["summary"]["percent_covered"]) / 100.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IstanbulReport:
|
|
39
|
+
"""istanbul `coverage-final.json`: keys are absolute paths; statementMap/s
|
|
40
|
+
and branchMap/b give per-location hit counts."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, data: dict, repo_root: Path) -> None:
|
|
43
|
+
self._files: dict[str, dict] = {}
|
|
44
|
+
root = repo_root.resolve()
|
|
45
|
+
for key, entry in data.items():
|
|
46
|
+
p = Path(entry.get("path", key))
|
|
47
|
+
try:
|
|
48
|
+
rel = p.resolve().relative_to(root).as_posix() if p.is_absolute() else p.as_posix()
|
|
49
|
+
except ValueError:
|
|
50
|
+
rel = p.as_posix()
|
|
51
|
+
self._files[rel] = entry
|
|
52
|
+
|
|
53
|
+
def has_file(self, file: str) -> bool:
|
|
54
|
+
return file in self._files
|
|
55
|
+
|
|
56
|
+
def function_coverage(self, func: Func, children: list[Func]) -> float:
|
|
57
|
+
entry = self._files[func.file]
|
|
58
|
+
|
|
59
|
+
def owned(loc: dict) -> bool:
|
|
60
|
+
line = loc["start"]["line"]
|
|
61
|
+
return func.start <= line <= func.end and not any(c.start <= line <= c.end for c in children)
|
|
62
|
+
|
|
63
|
+
total = covered = 0
|
|
64
|
+
for sid, loc in entry.get("statementMap", {}).items():
|
|
65
|
+
if owned(loc):
|
|
66
|
+
total += 1
|
|
67
|
+
covered += 1 if entry.get("s", {}).get(sid, 0) > 0 else 0
|
|
68
|
+
for bid, branch in entry.get("branchMap", {}).items():
|
|
69
|
+
loc = branch.get("loc") or (branch.get("locations") or [None])[0]
|
|
70
|
+
if loc and owned(loc):
|
|
71
|
+
counts = entry.get("b", {}).get(bid, [])
|
|
72
|
+
total += len(counts)
|
|
73
|
+
covered += sum(1 for c in counts if c > 0)
|
|
74
|
+
return 1.0 if total == 0 else covered / total
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_report(target: Target, repo_root: Path) -> Report:
|
|
78
|
+
path = repo_root / target.coverage_report
|
|
79
|
+
try:
|
|
80
|
+
data = json.loads(path.read_text())
|
|
81
|
+
except (OSError, ValueError) as exc:
|
|
82
|
+
raise ToolError(f"{target.name}: cannot read coverage report {target.coverage_report}: {exc}") from exc
|
|
83
|
+
if target.coverage_format == "coverage.py":
|
|
84
|
+
return CoveragePyReport(data, target.coverage_root)
|
|
85
|
+
return IstanbulReport(data, repo_root)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Thin git wrapper: what changed against the base, and base file contents."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from crap_model import ToolError
|
|
9
|
+
|
|
10
|
+
_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
14
|
+
proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
|
|
15
|
+
if check and proc.returncode != 0:
|
|
16
|
+
raise ToolError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
17
|
+
return proc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def toplevel(cwd: Path) -> Path:
|
|
21
|
+
return Path(_git(cwd, "rev-parse", "--show-toplevel").stdout.strip())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def merge_base(root: Path, ref: str) -> str:
|
|
25
|
+
proc = _git(root, "merge-base", "HEAD", ref, check=False)
|
|
26
|
+
if proc.returncode != 0:
|
|
27
|
+
raise ToolError(f"cannot resolve base {ref!r}: {proc.stderr.strip()} (fetch it or pass --base)")
|
|
28
|
+
return proc.stdout.strip()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def changed_files(root: Path, base: str) -> list[tuple[str, str | None]]:
|
|
32
|
+
"""(path, old_path) for every file that differs between the working tree
|
|
33
|
+
and `base`, plus untracked files. Deleted files are omitted."""
|
|
34
|
+
out: dict[str, str | None] = {}
|
|
35
|
+
for line in _git(root, "diff", "--name-status", "-M", base).stdout.splitlines():
|
|
36
|
+
parts = line.split("\t")
|
|
37
|
+
status = parts[0][0]
|
|
38
|
+
if status == "D":
|
|
39
|
+
continue
|
|
40
|
+
if status == "R":
|
|
41
|
+
out[parts[2]] = parts[1]
|
|
42
|
+
elif status == "A":
|
|
43
|
+
out[parts[1]] = None
|
|
44
|
+
else:
|
|
45
|
+
out[parts[1]] = parts[1]
|
|
46
|
+
for path in _git(root, "ls-files", "--others", "--exclude-standard").stdout.splitlines():
|
|
47
|
+
out[path] = None
|
|
48
|
+
return sorted(out.items())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def touched_lines(root: Path, base: str, path: str) -> list[tuple[int, int]]:
|
|
52
|
+
"""Inclusive line ranges of the working-tree file touched since `base`.
|
|
53
|
+
A pure deletion at +c touches c and c+1 (the seam)."""
|
|
54
|
+
ranges: list[tuple[int, int]] = []
|
|
55
|
+
for line in _git(root, "diff", "-U0", base, "--", path).stdout.splitlines():
|
|
56
|
+
m = _HUNK.match(line)
|
|
57
|
+
if not m:
|
|
58
|
+
continue
|
|
59
|
+
start = int(m.group(1))
|
|
60
|
+
count = int(m.group(2)) if m.group(2) is not None else 1
|
|
61
|
+
ranges.append((start, start + count - 1) if count else (start, start + 1))
|
|
62
|
+
return ranges
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def show(root: Path, rev: str, path: str) -> str | None:
|
|
66
|
+
proc = _git(root, "show", f"{rev}:{path}", check=False)
|
|
67
|
+
return proc.stdout if proc.returncode == 0 else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tracked_files(root: Path) -> list[str]:
|
|
71
|
+
"""Tracked + untracked files that exist in the working tree."""
|
|
72
|
+
listed = _git(root, "ls-files", "--cached", "--others", "--exclude-standard").stdout.splitlines()
|
|
73
|
+
return sorted(p for p in set(listed) if (root / p).is_file())
|