@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.
@@ -0,0 +1,33 @@
1
+ """Dataclasses shared by every crap-gate module. Stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ class ToolError(Exception):
8
+ """Configuration or tooling problem: the CLI exits 2 with the message."""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Func:
13
+ file: str # repo-relative path with forward slashes
14
+ name: str # qualified name: "outer.inner", "Class.method", "default"
15
+ start: int # 1-based first line of the declaration
16
+ end: int # 1-based last line, inclusive
17
+ cc: int # cyclomatic complexity, anonymous descendants folded in
18
+
19
+ def contains(self, other: "Func") -> bool:
20
+ return other is not self and self.start <= other.start and other.end <= self.end
21
+
22
+
23
+ @dataclass
24
+ class Scored:
25
+ target: str
26
+ func: Func
27
+ coverage: float # 0.0 .. 1.0
28
+ crap: float
29
+ kind: str # "new" | "existing"
30
+ limit: float
31
+ status: str # "FAIL" | "ok"
32
+ hint: str # cheapest way to pass; "" when ok
33
+ note: str = "" # e.g. "file absent from coverage report"
@@ -0,0 +1,69 @@
1
+ """Named functions and cyclomatic complexity for Python via `ast`. Stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+
6
+ from crap_model import Func, ToolError
7
+
8
+ _DEFS = (ast.FunctionDef, ast.AsyncFunctionDef)
9
+ _SCOPES = (*_DEFS, ast.ClassDef)
10
+ _BRANCHES = (
11
+ ast.If,
12
+ ast.For,
13
+ ast.AsyncFor,
14
+ ast.While,
15
+ ast.ExceptHandler,
16
+ ast.IfExp,
17
+ ast.match_case,
18
+ )
19
+
20
+
21
+ def functions(source: str, file: str) -> list[Func]:
22
+ try:
23
+ tree = ast.parse(source, filename=file)
24
+ except SyntaxError as exc:
25
+ raise ToolError(f"{file}: cannot parse: {exc.msg} (line {exc.lineno})") from exc
26
+ out: list[Func] = []
27
+ for node in tree.body:
28
+ _visit(node, "", out, file)
29
+ return out
30
+
31
+
32
+ def _visit(node: ast.AST, prefix: str, out: list[Func], file: str) -> None:
33
+ if isinstance(node, _DEFS):
34
+ name = prefix + node.name
35
+ cc, nested = _complexity(node)
36
+ out.append(Func(file, name, node.lineno, node.end_lineno or node.lineno, cc))
37
+ for child in nested:
38
+ _visit(child, name + ".", out, file)
39
+ elif isinstance(node, ast.ClassDef):
40
+ for child in node.body:
41
+ _visit(child, prefix + node.name + ".", out, file)
42
+ else:
43
+ # Compound statements outside any function (if TYPE_CHECKING:, try:, …)
44
+ # may still hold defs; keep the same prefix.
45
+ for child in ast.iter_child_nodes(node):
46
+ if isinstance(child, (ast.stmt, ast.ExceptHandler)):
47
+ _visit(child, prefix, out, file)
48
+
49
+
50
+ def _complexity(fn: ast.AST) -> tuple[int, list[ast.AST]]:
51
+ """CC of `fn` counting everything except nested named scopes, which are
52
+ returned (in source order) for separate scoring. Lambdas fold in."""
53
+ cc = 1
54
+ nested: list[ast.AST] = []
55
+ stack = list(ast.iter_child_nodes(fn))
56
+ while stack:
57
+ node = stack.pop()
58
+ if isinstance(node, _SCOPES):
59
+ nested.append(node)
60
+ continue
61
+ if isinstance(node, _BRANCHES):
62
+ cc += 1
63
+ elif isinstance(node, ast.BoolOp):
64
+ cc += len(node.values) - 1
65
+ elif isinstance(node, ast.comprehension):
66
+ cc += 1 + len(node.ifs)
67
+ stack.extend(ast.iter_child_nodes(node))
68
+ nested.sort(key=lambda n: n.lineno)
69
+ return cc, nested
@@ -0,0 +1,99 @@
1
+ """Text and JSON rendering of an engine Result. Stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict, dataclass
6
+
7
+ from crap_model import Scored
8
+
9
+
10
+ @dataclass
11
+ class TargetRun:
12
+ name: str
13
+ report: str
14
+ ran: bool
15
+ seconds: float
16
+
17
+
18
+ @dataclass
19
+ class Result:
20
+ base: str
21
+ base_ref: str
22
+ thresholds: dict[str, float]
23
+ targets: list[TargetRun]
24
+ functions: list[Scored]
25
+ not_measured: list[str]
26
+
27
+ @property
28
+ def summary(self) -> dict[str, int]:
29
+ return {
30
+ "fail": sum(1 for s in self.functions if s.status == "FAIL"),
31
+ "ok": sum(1 for s in self.functions if s.status == "ok"),
32
+ "not_measured": len(self.not_measured),
33
+ }
34
+
35
+
36
+ def _line(s: Scored) -> str:
37
+ text = (
38
+ f"{s.status:<4} {s.func.file}:{s.func.start} {s.func.name} CC {s.func.cc} "
39
+ f"cov {round(s.coverage * 100)}% CRAP {s.crap:.1f} limit {s.limit:g} ({s.kind})"
40
+ )
41
+ if s.hint:
42
+ text += f" → {s.hint}"
43
+ if s.note:
44
+ text += f" [{s.note}]"
45
+ return text
46
+
47
+
48
+ def render_text(result: Result) -> str:
49
+ lines = [
50
+ f"CRAP gate base {result.base[:7]} (merge-base HEAD..{result.base_ref}) "
51
+ f"limits: existing > {result.thresholds['existing']:g}, new > {result.thresholds['new']:g}"
52
+ ]
53
+ for t in result.targets:
54
+ state = f"ran, {t.seconds:.0f}s" if t.ran else "reused"
55
+ lines.append(f"{t.name:<9} report {t.report} ({state})")
56
+ lines.append("")
57
+ if result.functions:
58
+ lines.extend(_line(s) for s in sorted(result.functions, key=lambda s: -s.crap))
59
+ else:
60
+ lines.append("no changed functions in scope")
61
+ if result.not_measured:
62
+ lines.append("")
63
+ lines.append("Not measured: " + ", ".join(result.not_measured))
64
+ summary = result.summary
65
+ lines.append("")
66
+ lines.append(f"Summary: {summary['fail']} FAIL, {summary['ok']} ok, {summary['not_measured']} not measured")
67
+ return "\n".join(lines) + "\n"
68
+
69
+
70
+ def render_json(result: Result) -> str:
71
+ functions = []
72
+ for s in sorted(result.functions, key=lambda s: -s.crap):
73
+ functions.append(
74
+ {
75
+ "target": s.target,
76
+ "file": s.func.file,
77
+ "name": s.func.name,
78
+ "start": s.func.start,
79
+ "end": s.func.end,
80
+ "cc": s.func.cc,
81
+ "coverage": round(s.coverage, 4),
82
+ "crap": round(s.crap, 1),
83
+ "kind": s.kind,
84
+ "limit": s.limit,
85
+ "status": s.status,
86
+ "hint": s.hint,
87
+ "note": s.note,
88
+ }
89
+ )
90
+ payload = {
91
+ "base": result.base,
92
+ "base_ref": result.base_ref,
93
+ "thresholds": result.thresholds,
94
+ "targets": [asdict(t) for t in result.targets],
95
+ "functions": functions,
96
+ "not_measured": result.not_measured,
97
+ "summary": result.summary,
98
+ }
99
+ return json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
@@ -0,0 +1,65 @@
1
+ """CRAP formula and threshold logic. Stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+
6
+ from crap_model import Func, Scored
7
+
8
+
9
+ def crap_score(cc: int, coverage: float) -> float:
10
+ """CRAP = CC² · (1 − cov)³ + CC (Savoia/Cunningham)."""
11
+ return cc * cc * (1.0 - coverage) ** 3 + cc
12
+
13
+
14
+ def required_coverage(cc: int, limit: float) -> float | None:
15
+ """Smallest coverage at which crap_score(cc, cov) <= limit; None when
16
+ even 100% coverage cannot bring CC under the limit."""
17
+ if cc > limit:
18
+ return None
19
+ if cc == limit:
20
+ return 1.0
21
+ return max(0.0, 1.0 - ((limit - cc) / (cc * cc)) ** (1.0 / 3.0))
22
+
23
+
24
+ def max_cc(coverage: float, limit: float) -> int:
25
+ """Largest CC that still passes at the given coverage."""
26
+ cc = 1
27
+ while crap_score(cc + 1, coverage) <= limit:
28
+ cc += 1
29
+ return cc
30
+
31
+
32
+ def hint(cc: int, coverage: float, limit: float) -> str:
33
+ """The cheapest ways out for a failing function, as one short phrase."""
34
+ parts: list[str] = []
35
+ need = required_coverage(cc, limit)
36
+ if need is not None and need > coverage:
37
+ parts.append(f"cover ≥ {math.ceil(need * 100)}%")
38
+ ceiling = max_cc(coverage, limit)
39
+ if ceiling < cc:
40
+ parts.append(f"split to CC ≤ {ceiling}")
41
+ return " or ".join(parts)
42
+
43
+
44
+ def score(
45
+ target: str,
46
+ func: Func,
47
+ coverage: float,
48
+ kind: str,
49
+ thresholds: dict[str, float],
50
+ note: str = "",
51
+ ) -> Scored:
52
+ limit = thresholds["new"] if kind == "new" else thresholds["existing"]
53
+ value = crap_score(func.cc, coverage)
54
+ failing = value > limit
55
+ return Scored(
56
+ target=target,
57
+ func=func,
58
+ coverage=coverage,
59
+ crap=value,
60
+ kind=kind,
61
+ limit=limit,
62
+ status="FAIL" if failing else "ok",
63
+ hint=hint(func.cc, coverage, limit) if failing else "",
64
+ note=note,
65
+ )
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Named functions + cyclomatic complexity for TS/TSX via the TypeScript
3
+ // compiler API. Reads {"files":[{"path","label"}]} on stdin, writes a JSON
4
+ // array of {file,name,start,end,cc} on stdout. `typescript` is resolved from
5
+ // the --cwd directory's node_modules so the analyzer follows the project's
6
+ // own compiler version and needs no install of its own.
7
+ import { readFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import path from 'node:path';
10
+ import { pathToFileURL } from 'node:url';
11
+
12
+ const cwdIndex = process.argv.indexOf('--cwd');
13
+ const cwd = path.resolve(cwdIndex >= 0 ? process.argv[cwdIndex + 1] : process.cwd());
14
+ let ts;
15
+ try {
16
+ ts = createRequire(pathToFileURL(path.join(cwd, 'package.json')))('typescript');
17
+ } catch (err) {
18
+ console.error(`cannot load typescript from ${cwd}/node_modules: ${err.message}`);
19
+ process.exit(3);
20
+ }
21
+
22
+ const K = ts.SyntaxKind;
23
+ const BRANCH_KINDS = new Set([
24
+ K.IfStatement, K.ConditionalExpression, K.ForStatement, K.ForInStatement,
25
+ K.ForOfStatement, K.WhileStatement, K.DoStatement, K.CatchClause, K.CaseClause,
26
+ ]);
27
+ const LOGICAL_OPERATORS = new Set([
28
+ K.AmpersandAmpersandToken, K.BarBarToken, K.QuestionQuestionToken,
29
+ K.AmpersandAmpersandEqualsToken, K.BarBarEqualsToken, K.QuestionQuestionEqualsToken,
30
+ ]);
31
+
32
+ const scriptKind = (file) =>
33
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX
34
+ : file.endsWith('.jsx') ? ts.ScriptKind.JSX
35
+ : file.endsWith('.js') || file.endsWith('.mjs') ? ts.ScriptKind.JS
36
+ : ts.ScriptKind.TS;
37
+
38
+ const isFunctionLike = (n) =>
39
+ ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) ||
40
+ ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) ||
41
+ ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n);
42
+
43
+ const hasDefaultModifier = (n) =>
44
+ (ts.canHaveModifiers(n) ? ts.getModifiers(n) ?? [] : []).some((m) => m.kind === K.DefaultKeyword);
45
+
46
+ const memberName = (n) => (n.name && ts.isIdentifier(n.name) ? n.name.text : n.name?.getText() ?? '<computed>');
47
+
48
+ // Direct naming (any depth): declarations, class members, direct initialisers.
49
+ function directName(n, prefix) {
50
+ if (ts.isFunctionDeclaration(n)) return n.name ? prefix + n.name.text : hasDefaultModifier(n) ? prefix + 'default' : null;
51
+ if (ts.isConstructorDeclaration(n)) return prefix + 'constructor';
52
+ if (ts.isMethodDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)) {
53
+ return ts.isClassLike(n.parent) ? prefix + memberName(n) : null; // object-literal methods fold
54
+ }
55
+ const p = n.parent;
56
+ if (ts.isVariableDeclaration(p) && ts.isIdentifier(p.name)) return prefix + p.name.text;
57
+ if (ts.isExportAssignment(p)) return prefix + 'default';
58
+ return null;
59
+ }
60
+
61
+ // Module-level only: `export const X = memo(forwardRef((p, r) => …))` is X.
62
+ function wrapperName(n, prefix) {
63
+ let node = n;
64
+ for (;;) {
65
+ const p = node.parent;
66
+ if (!p) return null;
67
+ if (ts.isCallExpression(p) && p.arguments.includes(node)) { node = p; continue; }
68
+ if (ts.isParenthesizedExpression(p) || ts.isAsExpression(p) || ts.isSatisfiesExpression(p)) { node = p; continue; }
69
+ if (ts.isVariableDeclaration(p) && ts.isIdentifier(p.name) && p.initializer === node) return prefix + p.name.text;
70
+ if (ts.isExportAssignment(p)) return prefix + 'default';
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function analyze(file, label) {
76
+ const text = readFileSync(file, 'utf8');
77
+ const sf = ts.createSourceFile(label, text, ts.ScriptTarget.Latest, true, scriptKind(file));
78
+ const line = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
79
+ const out = [];
80
+
81
+ function visit(node, prefix, current) {
82
+ if (BRANCH_KINDS.has(node.kind) || (ts.isBinaryExpression(node) && LOGICAL_OPERATORS.has(node.operatorToken.kind))) {
83
+ if (current) current.cc += 1;
84
+ }
85
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
86
+ const className = node.name ? node.name.text : '<class>';
87
+ ts.forEachChild(node, (c) => visit(c, prefix + className + '.', current));
88
+ return;
89
+ }
90
+ if (isFunctionLike(node)) {
91
+ const name = directName(node, prefix) ?? (current ? null : wrapperName(node, prefix));
92
+ if (name) {
93
+ const entry = { file: label, name, start: line(node.getStart(sf)), end: line(node.getEnd()), cc: 1 };
94
+ out.push(entry);
95
+ ts.forEachChild(node, (c) => visit(c, name + '.', entry));
96
+ return;
97
+ }
98
+ // anonymous: fold into `current` (or ignore when there is none)
99
+ }
100
+ ts.forEachChild(node, (c) => visit(c, prefix, current));
101
+ }
102
+
103
+ visit(sf, '', null);
104
+ return out;
105
+ }
106
+
107
+ const input = JSON.parse(readFileSync(0, 'utf8'));
108
+ const results = [];
109
+ for (const { path: file, label } of input.files) results.push(...analyze(file, label));
110
+ process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,30 @@
1
+ """Run crap_typescript.mjs and map its output to Func. Stdlib only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from crap_model import Func, ToolError
10
+
11
+ SCRIPT = Path(__file__).with_name("crap_typescript.mjs")
12
+
13
+
14
+ def functions(files: list[tuple[Path, str]], cwd: Path) -> dict[str, list[Func]]:
15
+ """Analyse (path_on_disk, label) pairs in one node process; result keyed by label."""
16
+ if shutil.which("node") is None:
17
+ raise ToolError("node is required for the typescript analyzer but is not on PATH")
18
+ payload = json.dumps({"files": [{"path": str(p), "label": label} for p, label in files]})
19
+ proc = subprocess.run(
20
+ ["node", str(SCRIPT), "--cwd", str(cwd)],
21
+ input=payload,
22
+ capture_output=True,
23
+ text=True,
24
+ )
25
+ if proc.returncode != 0:
26
+ raise ToolError(f"typescript analyzer failed (cwd {cwd}): {proc.stderr.strip()}")
27
+ result: dict[str, list[Func]] = {label: [] for _, label in files}
28
+ for item in json.loads(proc.stdout):
29
+ result[item["file"]].append(Func(item["file"], item["name"], item["start"], item["end"], item["cc"]))
30
+ return result
@@ -0,0 +1,152 @@
1
+ import json
2
+ import subprocess
3
+ import textwrap
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ import crap
9
+
10
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
11
+
12
+ # A stand-in for `pytest --cov`: writes a coverage.py-shaped report where
13
+ # every function is 100% covered except those whose name starts with `un`.
14
+ FAKE_COVERAGE = textwrap.dedent('''
15
+ import ast, json, sys
16
+ from pathlib import Path
17
+ files = {}
18
+ for p in Path("pkg").glob("*.py"):
19
+ fns = {}
20
+ for node in ast.walk(ast.parse(p.read_text())):
21
+ if isinstance(node, ast.FunctionDef):
22
+ pct = 0.0 if node.name.startswith("un") else 100.0
23
+ fns[node.name] = {"start_line": node.lineno, "summary": {"percent_covered": pct}}
24
+ files[p.as_posix()] = {"functions": fns}
25
+ Path(".crap").mkdir(exist_ok=True)
26
+ Path(".crap/coverage.json").write_text(json.dumps({"files": files}))
27
+ ''')
28
+
29
+ CONFIG = {
30
+ "thresholds": {"existing": 15, "new": 9},
31
+ "base": "main",
32
+ "targets": [
33
+ {
34
+ "name": "backend",
35
+ "paths": ["pkg/**/*.py"],
36
+ "exclude": ["pkg/tests/**"],
37
+ "complexity": {"tool": "python"},
38
+ "coverage": {
39
+ "format": "coverage.py",
40
+ "root": ".",
41
+ "report": ".crap/coverage.json",
42
+ "command": "python3 fake_cov.py",
43
+ },
44
+ }
45
+ ],
46
+ }
47
+
48
+ BASE_SRC = "def f(x):\n return x\n\n\ndef unloved(a, b):\n if a:\n return b\n return a or b\n"
49
+
50
+
51
+ def git(root, *args):
52
+ return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout
53
+
54
+
55
+ @pytest.fixture
56
+ def repo(tmp_path, monkeypatch):
57
+ git(tmp_path, "init", "-q", "-b", "main")
58
+ (tmp_path / ".crap-gate.json").write_text(json.dumps(CONFIG))
59
+ (tmp_path / "fake_cov.py").write_text(FAKE_COVERAGE)
60
+ (tmp_path / ".gitignore").write_text(".crap/\n")
61
+ (tmp_path / "pkg").mkdir()
62
+ (tmp_path / "pkg" / "m.py").write_text(BASE_SRC)
63
+ git(tmp_path, "add", ".")
64
+ git(tmp_path, "commit", "-q", "-m", "base")
65
+ git(tmp_path, "switch", "-q", "-c", "feature")
66
+ monkeypatch.chdir(tmp_path)
67
+ return tmp_path
68
+
69
+
70
+ def test_clean_branch_exits_zero(repo, capsys):
71
+ assert crap.main([]) == 0
72
+ out = capsys.readouterr().out
73
+ assert "no changed functions" in out
74
+ assert not (repo / ".crap" / "coverage.json").exists() # nothing to measure → no run
75
+
76
+
77
+ def test_new_untested_function_fails_with_new_limit_and_hint(repo, capsys):
78
+ (repo / "pkg" / "m.py").write_text(BASE_SRC + "\n\ndef unnew(p, q):\n return p if q else (q if p else 0)\n")
79
+ assert crap.main([]) == 1
80
+ out = capsys.readouterr().out
81
+ assert "FAIL pkg/m.py:11 unnew CC 3 cov 0% CRAP 12.0 limit 9 (new) → cover ≥ 13% or split to CC ≤ 2" in out
82
+ assert "Summary: 1 FAIL, 0 ok, 0 not measured" in out
83
+
84
+
85
+ def test_touching_existing_function_uses_existing_limit(repo, capsys):
86
+ src = BASE_SRC.replace(" return a or b", " return a or b or 0") # unloved: CC 4 → CRAP 20 > 15
87
+ (repo / "pkg" / "m.py").write_text(src)
88
+ assert crap.main(["--json"]) == 1
89
+ data = json.loads(capsys.readouterr().out)
90
+ fn = data["functions"][0]
91
+ assert (fn["name"], fn["kind"], fn["limit"], fn["status"], fn["cc"], fn["crap"]) == ("unloved", "existing", 15, "FAIL", 4, 20.0)
92
+ (run,) = data["targets"]
93
+ assert (run["name"], run["report"], run["ran"]) == ("backend", ".crap/coverage.json", True)
94
+ assert run["seconds"] >= 0
95
+ assert data["summary"] == {"fail": 1, "ok": 0, "not_measured": 0}
96
+
97
+
98
+ def test_untouched_functions_are_not_reported(repo, capsys):
99
+ (repo / "pkg" / "m.py").write_text(BASE_SRC.replace(" return x", " return x + 1"))
100
+ assert crap.main([]) == 0
101
+ out = capsys.readouterr().out
102
+ assert "ok pkg/m.py:1 f CC 1 cov 100% CRAP 1.0 limit 15 (existing)" in out
103
+ assert "unloved" not in out
104
+
105
+
106
+ def test_out_of_scope_code_files_are_listed_not_measured(repo, capsys):
107
+ (repo / "pkg" / "tests").mkdir()
108
+ (repo / "pkg" / "tests" / "test_m.py").write_text("def test_it():\n assert True\n")
109
+ (repo / "README.md").write_text("hi\n")
110
+ assert crap.main([]) == 0
111
+ out = capsys.readouterr().out
112
+ assert "Not measured: pkg/tests/test_m.py" in out
113
+ assert "README.md" not in out
114
+
115
+
116
+ def test_all_scores_everything_as_existing_and_flags_override(repo, capsys):
117
+ assert crap.main(["--all", "--threshold-existing", "2"]) == 1
118
+ out = capsys.readouterr().out
119
+ assert "FAIL pkg/m.py:5 unloved CC 3 cov 0% CRAP 12.0 limit 2 (existing)" in out
120
+ assert "ok pkg/m.py:1 f CC 1" in out
121
+
122
+
123
+ def test_no_run_without_report_is_a_tool_error(repo, capsys):
124
+ (repo / "pkg" / "m.py").write_text(BASE_SRC + "\n\ndef k():\n return 1\n")
125
+ assert crap.main(["--no-run"]) == 2
126
+ assert "coverage.json" in capsys.readouterr().err
127
+
128
+
129
+ def test_failing_coverage_command_that_still_writes_a_report_warns(repo, capsys):
130
+ cfg = json.loads((repo / ".crap-gate.json").read_text())
131
+ cfg["targets"][0]["coverage"]["command"] = "python3 fake_cov.py && exit 3"
132
+ (repo / ".crap-gate.json").write_text(json.dumps(cfg))
133
+ (repo / "pkg" / "m.py").write_text(BASE_SRC + "\n\ndef k():\n return 1\n")
134
+ assert crap.main([]) == 0
135
+ assert "exited 3" in capsys.readouterr().err
136
+
137
+
138
+ def test_unknown_base_is_a_tool_error(repo, capsys):
139
+ assert crap.main(["--base", "nope"]) == 2
140
+ assert "nope" in capsys.readouterr().err
141
+
142
+
143
+ def test_config_not_at_git_toplevel_is_a_tool_error(tmp_path, monkeypatch, capsys):
144
+ git(tmp_path, "init", "-q", "-b", "main")
145
+ sub = tmp_path / "sub"
146
+ sub.mkdir()
147
+ (sub / ".crap-gate.json").write_text(json.dumps(CONFIG))
148
+ git(tmp_path, "add", ".")
149
+ git(tmp_path, "commit", "-q", "-m", "base")
150
+ monkeypatch.chdir(sub)
151
+ assert crap.main([]) == 2
152
+ assert "git repository root" in capsys.readouterr().err
@@ -0,0 +1,94 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from crap_config import CODE_SUFFIXES, Target, find_config, load, matches
6
+ from crap_model import ToolError
7
+
8
+ MINIMAL = {
9
+ "targets": [
10
+ {
11
+ "name": "backend",
12
+ "paths": ["backend/**/*.py"],
13
+ "exclude": ["backend/tests/**", "backend/seed_*.py"],
14
+ "complexity": {"tool": "python"},
15
+ "coverage": {
16
+ "format": "coverage.py",
17
+ "root": "backend",
18
+ "report": "backend/.crap/coverage.json",
19
+ "command": "true",
20
+ },
21
+ }
22
+ ]
23
+ }
24
+
25
+
26
+ def write(tmp_path, data):
27
+ p = tmp_path / ".crap-gate.json"
28
+ p.write_text(json.dumps(data))
29
+ return p
30
+
31
+
32
+ def test_find_config_walks_up(tmp_path):
33
+ p = write(tmp_path, MINIMAL)
34
+ nested = tmp_path / "a" / "b"
35
+ nested.mkdir(parents=True)
36
+ assert find_config(nested) == p
37
+
38
+
39
+ def test_find_config_raises_when_absent(tmp_path):
40
+ with pytest.raises(ToolError, match=r"\.crap-gate\.json"):
41
+ find_config(tmp_path)
42
+
43
+
44
+ def test_defaults_and_overrides(tmp_path):
45
+ cfg = load(write(tmp_path, MINIMAL), {})
46
+ assert cfg.thresholds == {"existing": 15, "new": 9}
47
+ assert cfg.base == "origin/main"
48
+ assert cfg.root == tmp_path
49
+ cfg = load(write(tmp_path, MINIMAL), {"base": "main", "threshold_new": 5})
50
+ assert (cfg.base, cfg.thresholds["new"], cfg.thresholds["existing"]) == ("main", 5, 15)
51
+
52
+
53
+ def test_target_fields(tmp_path):
54
+ t = load(write(tmp_path, MINIMAL), {}).targets[0]
55
+ assert t == Target(
56
+ name="backend",
57
+ paths=["backend/**/*.py"],
58
+ exclude=["backend/tests/**", "backend/seed_*.py"],
59
+ complexity_tool="python",
60
+ complexity_cwd=".",
61
+ coverage_format="coverage.py",
62
+ coverage_report="backend/.crap/coverage.json",
63
+ coverage_command="true",
64
+ coverage_root="backend",
65
+ )
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ "bad, message",
70
+ [
71
+ ({"targets": []}, "at least one target"),
72
+ ({"targets": [{**MINIMAL["targets"][0], "complexity": {"tool": "lizard"}}]}, "complexity.tool"),
73
+ ({"targets": [{**MINIMAL["targets"][0], "coverage": {**MINIMAL["targets"][0]["coverage"], "format": "lcov"}}]}, "coverage.format"),
74
+ ({"targets": [MINIMAL["targets"][0], MINIMAL["targets"][0]]}, "duplicate target name"),
75
+ ({"thresholds": {"existing": "x"}, **MINIMAL}, "thresholds"),
76
+ ],
77
+ )
78
+ def test_validation(tmp_path, bad, message):
79
+ with pytest.raises(ToolError, match=message):
80
+ load(write(tmp_path, bad), {})
81
+
82
+
83
+ def test_matches_globs_with_double_star_and_exclude(tmp_path):
84
+ t = load(write(tmp_path, MINIMAL), {}).targets[0]
85
+ assert matches(t, "backend/main.py")
86
+ assert matches(t, "backend/services/deep/x.py")
87
+ assert not matches(t, "backend/tests/test_x.py")
88
+ assert not matches(t, "backend/seed_securities.py")
89
+ assert not matches(t, "frontend/src/a.ts")
90
+ assert not matches(t, "backend/notes.md")
91
+
92
+
93
+ def test_code_suffixes():
94
+ assert CODE_SUFFIXES == {".py", ".ts", ".tsx"}