@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
|
@@ -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"}
|