@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jesdi/skills",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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())