Add a multi-language CRAP tool that writes .metrics/crap.edn for uml-viewer.

Score Clojure, Java, Go, TypeScript, Rust, and Python with each language's complexity rules and coverage report.
This commit is contained in:
Robert C. Martin
2026-09-30 08:21:44 -05:00
commit a4b5fc2aed
33 changed files with 3913 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
import subprocess
from pathlib import Path
from crapper.cli import run
from crapper.discover import language_of
def test_language_detection():
assert language_of("src/app/core.clj") == "clojure"
assert language_of("src/app/core.cljc") == "clojure"
assert language_of("Widget.java") == "java"
assert language_of("board.go") == "go"
assert language_of("ui/view.tsx") == "typescript"
assert language_of("src/lib.rs") == "rust"
assert language_of("src/crapper/cli.py") == "python"
assert language_of("types.d.ts") is None
assert language_of("notes.md") is None
def _write(root: Path, relative: str, source: str) -> None:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
def test_project_snapshot_is_readable_as_crap4clj_edn(tmp_path):
_write(
tmp_path,
"src/demo/core.clj",
"(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n",
)
_write(
tmp_path,
"src/demo/Board.java",
"package demo;\npublic class Board {\n public int place(int x) {\n if (x > 0 && ready) return 1;\n return 0;\n }\n}\n",
)
_write(
tmp_path,
"board.go",
"package demo\n\nfunc Place(x int) int {\n\tif x > 0 {\n\t\treturn x\n\t}\n\treturn 0\n}\n",
)
_write(
tmp_path,
"src/ui/view.ts",
"export function view(ok: boolean): number {\n return ok ? 1 : 0;\n}\n",
)
_write(tmp_path, "Cargo.toml", '[package]\nname = "demo"\nversion = "0.1.0"\n')
_write(
tmp_path,
"src/lib.rs",
"pub fn open(ok: bool) -> i32 { if ok { 1 } else { 0 } }\n",
)
_write(tmp_path, "src/demo/core_test.clj", "(ns demo.core-test)\n(defn should-skip [] 1)\n")
_write(tmp_path, "board_test.go", "package demo\nfunc TestPlace(t *testing.T) {}\n")
code = run(["--root", str(tmp_path), "--no-coverage"])
assert code == 0
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
assert ':namespace "demo.core"' in text
assert ':name "choose"' in text
assert ':namespace "demo.Board"' in text
assert ':name "place"' in text
assert ':namespace "demo"' in text and ':name "Place"' in text
assert ':namespace "ui.view"' in text
assert ':namespace "demo"' in text and ':name "open"' in text
assert "should-skip" not in text
assert "TestPlace" not in text
reader = subprocess.run(
[
"bb",
"-e",
(
"(require '[clojure.edn :as edn])"
"(let [data (edn/read-string (slurp *in*))]"
" (doseq [entry (:entries data)]"
" (println (:namespace entry) (:name entry)"
" (:complexity entry) (pr-str (:coverage entry)))))"
),
],
input=text,
text=True,
capture_output=True,
check=False,
)
assert reader.returncode == 0, reader.stderr
assert "demo.core choose 2 nil" in reader.stdout
assert "demo.Board place 3 nil" in reader.stdout
def test_existing_coverage_feeds_the_snapshot(tmp_path):
_write(
tmp_path,
"src/demo/core.clj",
"(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n",
)
lcov = tmp_path / "target" / "coverage" / "lcov.info"
lcov.parent.mkdir(parents=True)
lcov.write_text(
"SF:src/demo/core.clj\nDA:3,1\nDA:4,1\nend_of_record\n",
encoding="utf-8",
)
code = run(
["--root", str(tmp_path), "--use-existing-coverage", "src/demo/core.clj"]
)
assert code == 0
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
assert ":coverage 100.0" in text
assert ":crap 2.0" in text
def test_threshold_exits_when_the_worst_score_is_too_high(tmp_path):
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n")
lcov = tmp_path / "target" / "coverage" / "lcov.info"
lcov.parent.mkdir(parents=True)
lcov.write_text("SF:src/demo/core.clj\nDA:3,0\nDA:4,0\nend_of_record\n", encoding="utf-8")
code = run(
[
"--root",
str(tmp_path),
"--use-existing-coverage",
"--threshold",
"1",
]
)
assert code == 2
def test_help_and_unknown_option(capsys):
assert run(["--help"]) == 0
assert "uml-viewer" in capsys.readouterr().out
assert run(["--not-an-option"]) == 1
assert run(["--root"]) == 1
assert run(["--threshold", "nope"]) == 1
assert run(["--no-coverage", "--coverage-command", "true"]) == 1
def test_selects_directories_filters_and_changed_files(tmp_path, monkeypatch, capsys):
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n")
_write(tmp_path, "notes.md", "not source\n")
assert run(["--root", str(tmp_path), "--no-coverage", "--source-root", "src"]) == 0
assert run(["--root", str(tmp_path), "--no-coverage", "src"]) == 0
assert run(["--root", str(tmp_path), "--no-coverage", "src/demo/core.clj", "core"]) == 0
assert run(["--root", str(tmp_path), "--no-coverage", "notes.md"]) == 0
assert "No source files" in capsys.readouterr().out
class Result:
returncode = 0
stdout = ' M src/demo/core.clj\nR old.clj -> src/demo/core.clj\n?? "src/demo/core.clj"\n'
stderr = ""
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *args, **kwargs: Result())
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
class Failed:
returncode = 1
stdout = ""
stderr = "git failed"
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *args, **kwargs: Failed())
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
def test_run_asks_for_coverage_when_it_is_enabled(tmp_path, monkeypatch):
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n x)\n")
monkeypatch.setattr("crapper.cli.run_coverage", lambda *args: None)
assert run(["--root", str(tmp_path), "--coverage-command", "true"]) == 0
+106
View File
@@ -0,0 +1,106 @@
from crapper.languages.clojure import (
cyclomatic_complexity,
extract_functions,
functions_in_source,
namespace_from_path,
)
def test_base_and_branch_forms():
assert cyclomatic_complexity("(defn foo [])") == 1
assert cyclomatic_complexity("(defn foo [x]\n (+ x 1))") == 1
assert cyclomatic_complexity("(defn foo [x]\n (if x 1 0))") == 2
assert cyclomatic_complexity("(defn foo [x]\n (if-not x 1 0))") == 2
assert cyclomatic_complexity("(defn foo [x]\n (if-let [y x] y 0))") == 2
assert cyclomatic_complexity("(defn foo [x]\n (when x 1))") == 2
assert cyclomatic_complexity("(defn foo [x]\n (when-first [y x] y))") == 2
assert cyclomatic_complexity("(defn foo [x y]\n (and x y))") == 2
assert cyclomatic_complexity("(defn foo [x y]\n (or x y))") == 2
assert cyclomatic_complexity("(defn foo [x]\n (loop [i 0] (recur (inc i))))") == 2
def test_cond_case_and_threading():
assert (
cyclomatic_complexity("(defn foo [x]\n (cond\n (= x 1) :one\n :else :other))")
== 3
)
assert cyclomatic_complexity("(defn foo [x]\n (condp = x\n 1 :one\n 2 :two))") == 3
assert cyclomatic_complexity("(defn foo [x]\n (case x\n 1 :one\n :other))") == 3
assert (
cyclomatic_complexity(
"(defn foo [x]\n (cond-> x\n (pos? x) inc\n (even? x) (* 2)))"
)
== 3
)
assert cyclomatic_complexity("(defn foo [x]\n (some-> x inc dec))") == 3
assert (
cyclomatic_complexity("(defn foo [x]\n (some->> x (map inc) (filter pos?)))") == 3
)
def test_ignores_decisions_in_strings_and_comments():
assert cyclomatic_complexity('(defn foo [x]\n "if when cond and or"\n x)') == 1
assert cyclomatic_complexity('(defn foo [x]\n "(if true 1 0)"\n x)') == 1
assert cyclomatic_complexity('(defn foo [x]\n ;; if when cond\n x)') == 1
assert cyclomatic_complexity('(defn foo [x]\n (if x :yes :no) ; and or when cond)') == 2
def test_map_literals_inside_cond_are_not_extra_clauses():
source = """(defn foo [cell]
(let [contents (:contents cell)]
(cond
(pred-a? contents)
{:type :army :mode :awake :owner (:owner contents) :aboard true}
(pred-b? contents)
{:type :fighter :mode :awake :owner (:owner contents) :fuel 20 :from-carrier true}
(pred-c? contents) contents
(pred-d? cell)
{:type :fighter :mode :awake :owner :player :fuel 20 :from-airport true}
:else nil)))"""
assert cyclomatic_complexity(source) == 6
def test_extracts_defn_names_lines_and_namespace():
source = "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n\n(defn- hidden [y]\n y)\n"
found = functions_in_source(source, "src/demo/core.clj", "/proj")
assert [(item.name, item.complexity, item.start_line) for item in found] == [
("choose", 2, 3),
("hidden", 1, 6),
]
assert {item.namespace for item in found} == {"demo.core"}
def test_top_level_def_does_not_attach_to_the_previous_function():
source = (
"(defn alpha [x]\n"
" (if x 1 0))\n\n"
"(def rules\n"
" [{:pred (fn [v]\n"
" (if v (when (pos? v) true) false))}])\n\n"
"(defn omega [y]\n"
" y)"
)
found = {item["name"]: item["complexity"] for item in extract_functions(source)}
assert found == {"alpha": 2, "omega": 1}
def test_namespace_falls_back_to_the_path():
assert namespace_from_path("src/demo/core_extra.clj", None) == "demo.core-extra"
assert namespace_from_path("/proj/src/demo/core.cljc", "/proj") == "demo.core"
assert namespace_from_path("pkg/src/demo/core.bb", None) == "demo.core"
found = functions_in_source("(defn foo [] 1)\n", "src/demo/core_extra.clj", "/proj")
assert found[0].namespace == "demo.core-extra"
def test_strings_comments_and_character_literals_stay_inside_one_form():
source = '(defn foo [x]\n "say \\"hi\\"\n (if x 1)"\n \\space\n ;; (defn hidden [])\n x)\n'
assert [item["name"] for item in extract_functions(source)] == ["foo"]
def test_character_literals_do_not_swallow_the_next_defn():
source = '(def x #{\\"})\n(defn foo [] 1)'
assert [item["name"] for item in extract_functions(source)] == ["foo"]
+137
View File
@@ -0,0 +1,137 @@
from crapper.coverage import (
CoverageBundle,
go_percent,
load_bundle,
parse_form_coverage,
parse_go_profile,
parse_jacoco_index,
parse_lcov,
)
from crapper.model import Function
def function(**kwargs):
defaults = dict(
name="choose",
namespace="demo.core",
complexity=2,
start_line=3,
end_line=5,
path="src/demo/core.clj",
language="clojure",
jacoco_class=None,
)
defaults.update(kwargs)
return Function(**defaults)
def test_cloverage_form_counts_and_lcov_lines():
html = '<span class="pc" title="1 out of 2 forms covered"> 4&nbsp;&nbsp;(if x 1)</span>'
assert parse_form_coverage(html) == {4: (1, 2)}
lcov = "SF:src/demo/core.clj\nDA:3,1\nDA:4,0\nDA:9,3\nend_of_record\n"
parsed = parse_lcov(lcov)
bundle = CoverageBundle(lcov=parsed, form_html={"src/demo/core.clj": {4: (1, 2)}})
# Form HTML wins over LCOV for Clojure, matching crap4clj.
assert bundle.percent_for(function(start_line=3, end_line=5)) == 50.0
def test_lcov_percentage_when_there_is_no_html():
lcov = parse_lcov("SF:src/demo/core.clj\nDA:3,1\nDA:4,0\nend_of_record\n")
bundle = CoverageBundle(lcov=lcov)
assert bundle.percent_for(function(start_line=3, end_line=4)) == 50.0
assert bundle.percent_for(function(path="src/missing.clj")) is None
def test_go_profile_matches_crap4go_statement_ranges():
profile = parse_go_profile(
"mode: set\ngithub.com/acme/demo/board.go:4.1,6.2 2 1\ngithub.com/acme/demo/board.go:8.1,9.2 1 0\n"
)
assert go_percent(profile, "board.go", 4, 6) == 100.0
assert go_percent(profile, "board.go", 8, 9) == 0.0
assert go_percent(profile, "other.go", 1, 3) is None
def test_jacoco_instruction_coverage_joins_on_class_and_method():
xml = """<?xml version="1.0"?>
<report>
<package>
<class name="demo/pkg/Board">
<method name="place" line="4">
<counter type="INSTRUCTION" missed="1" covered="3"/>
</method>
</class>
<class name="demo/pkg/Board$Inner">
<method name="tick" line="9">
<counter type="INSTRUCTION" missed="0" covered="2"/>
</method>
</class>
</package>
</report>
"""
bundle = CoverageBundle(jacoco=parse_jacoco_index(xml))
outer = function(
name="place",
namespace="demo.pkg.Board",
language="java",
jacoco_class="demo.pkg.Board",
start_line=4,
path="src/demo/pkg/Board.java",
)
inner = function(
name="tick",
namespace="demo.pkg.Board.Inner",
language="java",
jacoco_class="demo.pkg.Board$Inner",
start_line=9,
path="src/demo/pkg/Board.java",
)
assert bundle.percent_for(outer) == 75.0
assert bundle.percent_for(inner) == 100.0
def test_jacoco_skips_methods_without_an_instruction_counter():
xml = """<report>
<class name="demo/pkg/Board">
<method name="skip" line="2">
<counter type="LINE" missed="1" covered="0"/>
</method>
<method name="bad" line="nope">
<counter type="INSTRUCTION" missed="1" covered="1"/>
</method>
<counter type="INSTRUCTION" missed="0" covered="1"/>
</class>
</report>"""
found = parse_jacoco_index(xml)
assert list(found) == ["demo.pkg.Board#bad"]
assert found["demo.pkg.Board#bad"][0].line == 0
def test_missing_go_profile_is_na():
assert go_percent(None, "board.go", 1, 3) is None
def test_load_bundle_merges_each_report(tmp_path):
lcov = tmp_path / "target" / "coverage" / "lcov.info"
lcov.parent.mkdir(parents=True)
lcov.write_text("SF:src/demo/core.clj\nDA:3,1\nDA:4,0\nend_of_record\n", encoding="utf-8")
go = tmp_path / "target" / "coverage" / "go" / "coverage.out"
go.parent.mkdir(parents=True)
go.write_text("mode: set\nboard.go:1.1,2.2 1 1\n", encoding="utf-8")
jacoco = tmp_path / "target" / "site" / "jacoco" / "jacoco.xml"
jacoco.parent.mkdir(parents=True)
jacoco.write_text(
'<report><class name="demo/Board"><method name="place" line="4">'
'<counter type="INSTRUCTION" missed="0" covered="1"/></method></class></report>',
encoding="utf-8",
)
html = tmp_path / "target" / "coverage" / "src" / "demo" / "core.clj.html"
html.parent.mkdir(parents=True)
html.write_text(
'<span title="1 out of 2 forms covered"> 4&nbsp;&nbsp;(if x 1)</span>',
encoding="utf-8",
)
(tmp_path / "target" / "coverage" / "index.html").write_text("<html></html>", encoding="utf-8")
bundle = load_bundle(tmp_path)
assert bundle.percent_for(function(start_line=3, end_line=4)) == 50.0
assert bundle.go_profile is not None
assert bundle.jacoco is not None
+73
View File
@@ -0,0 +1,73 @@
from crapper.crap import crap_score, sort_entries
from crapper.metrics import render_edn
from crapper.model import Entry
from crapper.report import format_report
def entry(**kwargs):
defaults = dict(
name="score",
namespace="demo.core",
complexity=1,
coverage=100.0,
crap=1.0,
path="src/demo/core.clj",
language="clojure",
)
defaults.update(kwargs)
return Entry(**defaults)
def test_formula_matches_crap4clj():
assert crap_score(5, 100.0) == 5.0
assert crap_score(5, 0.0) == 30.0
assert crap_score(1, 100.0) == 1.0
assert abs(crap_score(8, 45.0) - 18.648) < 0.01
assert crap_score(3, None) is None
def test_unscored_rows_sort_after_scored_rows():
rows = sort_entries(
[
entry(name="missing", crap=None, coverage=None),
entry(name="mid", crap=5.0),
entry(name="worst", crap=30.0),
]
)
assert [row.name for row in rows] == ["worst", "mid", "missing"]
def test_unscored_rows_sort_by_complexity():
rows = sort_entries(
[
entry(name="small", crap=None, coverage=None, complexity=1),
entry(name="large", crap=None, coverage=None, complexity=8),
]
)
assert [row.name for row in rows] == ["large", "small"]
def test_report_shows_na_for_missing_coverage():
text = format_report([entry(coverage=None, crap=None, complexity=3)])
assert "CRAP Report" in text
assert "N/A" in text
assert "demo.core" in text
def test_edn_uses_crap4clj_keys():
text = render_edn(
[entry(name="layout", namespace="uml-viewer.layout", complexity=8, coverage=82.0, crap=3.4)]
)
assert text.startswith("{:entries [")
assert ':name "layout"' in text
assert ':namespace "uml-viewer.layout"' in text
assert ":complexity 8" in text
assert ":coverage 82.0" in text
assert ":crap 3.4" in text
assert text.endswith("}\n")
def test_edn_writes_nil_for_missing_coverage():
text = render_edn([entry(coverage=None, crap=None)])
assert ":coverage nil" in text
assert ":crap nil" in text
+62
View File
@@ -0,0 +1,62 @@
from crapper.languages.golang import functions_in_source
BRANCHY = """package sample
func Branchy(x int, ch chan int) int {
if x > 0 && x < 10 {
return 1
}
switch x {
case 1, 2:
return 2
default:
return 3
}
}
"""
def test_counts_the_same_decisions_as_crap4go():
functions = functions_in_source(BRANCHY, "sample.go")
assert [(fn.name, fn.namespace, fn.complexity) for fn in functions] == [
("Branchy", "sample", 5)
]
def test_methods_are_namespaced_by_receiver_type():
source = """package sample
type Widget struct{}
func (w *Widget) Run() {
for i := 0; i < 10; i++ {
}
for _, item := range items {
}
}
func Simple() int { return 1 }
"""
functions = functions_in_source(source, "widget.go")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("sample.Widget", "Run", 3),
("sample", "Simple", 1),
]
def test_import_path_comes_from_go_mod(tmp_path):
(tmp_path / "go.mod").write_text("module github.com/acme/demo\n", encoding="utf-8")
source_path = tmp_path / "internal" / "game" / "board.go"
source_path.parent.mkdir(parents=True)
source_path.write_text(
"package game\n\ntype Board struct{}\n\nfunc (b *Board) Place() {\n\tif ready && ok {\n\t\treturn\n\t}\n}\n",
encoding="utf-8",
)
functions = functions_in_source(
source_path.read_text(encoding="utf-8"),
"internal/game/board.go",
str(tmp_path),
)
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("github.com/acme/demo/internal/game.Board", "Place", 3)
]
+134
View File
@@ -0,0 +1,134 @@
from crapper.languages.java import functions_in_source
SCORE = """
class Sample {
int score(boolean a, boolean b, int[] values) {
for (int i = 0; i < values.length; i++) {
}
for (int value : values) {
}
while (a) {
a = false;
}
do {
b = false;
} while (b);
if (a && b || values.length > 0) {
}
try {
return a ? 1 : 0;
} catch (RuntimeException ex) {
return 2;
}
}
}
"""
NESTED = """
class Sample {
int nested(boolean a, boolean b, int[] values) {
for (int i = 0; i < values.length; i++) {
if (a) {
}
}
for (int value : values) {
if (b) {
}
}
while (a) {
if (b) {
}
a = false;
}
do {
if (a) {
}
b = false;
} while (b);
try {
return a ? (b ? 1 : 0) : 2;
} catch (RuntimeException ex) {
if (values.length > 0) {
return values[0];
}
return 3;
}
}
int switched(int value) {
switch (value) {
case 1:
if (value > 0) {
return 1;
}
return 0;
default:
return 2;
}
}
}
"""
ANONYMOUS = """
class Sample {
int outer() {
Runnable runnable = new Runnable() {
@Override
public void run() {
if (true) {
}
}
};
return 1;
}
}
"""
def test_counts_the_same_decisions_as_crap4java():
methods = functions_in_source(SCORE, "Sample.java")
assert [(method.name, method.complexity) for method in methods] == [("score", 10)]
methods = functions_in_source(NESTED, "Sample.java")
assert [(method.name, method.complexity) for method in methods] == [
("nested", 13),
("switched", 4),
]
def test_anonymous_class_methods_are_not_entries_and_do_not_raise_complexity():
methods = functions_in_source(ANONYMOUS, "Sample.java")
assert [(method.name, method.complexity) for method in methods] == [("outer", 1)]
def test_ignores_constructors_and_names_the_class_for_uml_viewer():
source = """
package demo.pkg;
public class Board {
Board() { if (true) {} }
public void place(int x) { if (x > 0 && ready) return; }
class Inner { void tick() { return; } }
}
"""
methods = functions_in_source(source, "src/demo/pkg/Board.java")
assert [(method.namespace, method.name, method.complexity) for method in methods] == [
("demo.pkg.Board", "place", 3),
("demo.pkg.Board.Inner", "tick", 1),
]
assert methods[0].jacoco_class == "demo.pkg.Board"
assert methods[1].jacoco_class == "demo.pkg.Board$Inner"
def test_keywords_in_comments_and_strings_are_not_decisions():
source = """
class Sample {
int stable() {
String text = "if && || ? case default catch";
// if && || ? case default catch
/* if && || ? case default catch */
return 1;
}
}
"""
methods = functions_in_source(source, "Sample.java")
assert [(method.name, method.complexity) for method in methods] == [("stable", 1)]
+105
View File
@@ -0,0 +1,105 @@
from crapper.languages.python import functions_in_source
def test_function_method_and_nested_function():
source = """
def choose(x, ready):
if x > 0 and x < 10:
return 1
elif x == 0 or ready:
return 0
for i in xs:
if i == 2:
return i
while ready:
break
try:
return x
except ValueError:
return 0
except KeyError:
return -1
match x:
case 1:
return 1
case 2 | 3:
return 2
case _:
return 0
return x if x > 0 else 0
class Box:
def open(self, flag=False):
return 1 if flag else 0
@staticmethod
def shut(self):
xs = [n for n in range(3) if n]
return xs
def outer():
def inner(n):
if n:
return 1
return 0
return inner(1)
async def load():
if True:
return 1
"""
functions = functions_in_source(source, "src/demo/box.py", "/proj")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo.box", "choose", 14),
("demo.box.Box", "open", 2),
("demo.box.Box", "shut", 2),
("demo.box", "outer", 2),
("demo.box", "load", 2),
]
def test_nested_class_method_is_its_own_entry():
source = """
class Outer:
class Inner:
def tick(self, ready, ok):
if ready and ok:
return 1
return 0
def factory():
class Hidden:
def secret(self):
if True:
return 1
return 0
return Hidden()
"""
functions = functions_in_source(source, "src/demo/sample.py", "/proj")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo.sample.Outer.Inner", "tick", 3),
("demo.sample", "factory", 1),
("demo.sample.Hidden", "secret", 2),
]
def test_namespace_strips_roots_src_and_init():
source = "def f():\n return 1\n"
assert functions_in_source(source, "/proj/src/demo/app.py", "/proj")[0].namespace == "demo.app"
assert functions_in_source(source, "./src/demo/app.py", ".")[0].namespace == "demo.app"
assert functions_in_source(source, "pkg/src/demo/__init__.py", None)[0].namespace == "demo"
assert functions_in_source(source, "__init__.py", None)[0].namespace == "__init__"
def test_strings_comments_and_package_init_do_not_add_decisions():
source = '''
def literal():
"""if and or for while except"""
text = "if x and y"
# if x: return 1
return text
'''
functions = functions_in_source(source, "src/demo/__init__.py", "/proj")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo", "literal", 1)
]
+252
View File
@@ -0,0 +1,252 @@
import json
from pathlib import Path
from crapper.discover import is_test_file
from crapper.runners import (
_clean_clojure,
_ensure_vitest_coverage,
_manifest_snapshot,
_python_executable,
_restore_manifests,
_rust_kind,
_vitest_version,
python_coverage_commands,
python_roots,
python_sources,
run_coverage,
rust_coverage_command,
rust_modules,
typescript_command,
typescript_packages,
uses_pytest,
)
def _write(root: Path, relative: str, source: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
return path
def test_vitest_uses_its_own_coverage_instead_of_c8(tmp_path):
_write(
tmp_path,
"package.json",
json.dumps({"scripts": {"test": "vitest run"}}),
)
_write(tmp_path, "vitest.config.ts", "export default {}\n")
source = _write(tmp_path, "src/book.ts", "export function plan() { return 1 }\n")
_write(tmp_path, "src/book.test.ts", "import { plan } from './book'\n")
report_dir = tmp_path / "target" / "coverage" / "typescript"
command = typescript_command(tmp_path, [source, tmp_path / "src" / "book.test.ts"], report_dir)
assert command is not None
assert "c8" not in command
assert "vitest run --coverage" in command
assert "--coverage.reporter=lcov" in command
assert f"--coverage.reportsDirectory={report_dir}" in command
assert "--coverage.include=src/book.ts" in command
assert "book.test.ts" not in command
def test_existing_coverage_script_is_left_alone(tmp_path):
_write(
tmp_path,
"package.json",
json.dumps({"scripts": {"coverage": "vitest run --coverage", "test": "vitest run"}}),
)
command = typescript_command(tmp_path, [], tmp_path / "coverage")
assert command == "npm run coverage"
def test_node_test_script_still_uses_c8(tmp_path):
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "node --test"}}))
report_dir = tmp_path / "target" / "coverage" / "typescript"
command = typescript_command(tmp_path, [], report_dir)
assert command == f"npx --yes c8 --reporter=lcov --reports-dir {report_dir} npm test"
def test_typescript_package_is_found_from_a_source_file(tmp_path):
_write(tmp_path, "web/package.json", json.dumps({"scripts": {"test": "vitest run"}}))
source = _write(tmp_path, "web/src/app.ts", "export const n = 1\n")
assert typescript_packages(tmp_path, [source]) == [(tmp_path / "web").resolve()]
def test_rust_module_is_the_nearest_cargo_package(tmp_path):
_write(tmp_path, "src-tauri/Cargo.toml", '[package]\nname = "bookwriter"\nversion = "0.1.0"\n')
lib = _write(tmp_path, "src-tauri/src/lib.rs", "pub fn read_text() {}\n")
assert rust_modules(tmp_path, [lib]) == [(tmp_path / "src-tauri").resolve()]
def test_manifests_are_restored_after_a_coverage_install(tmp_path):
package_json = tmp_path / "package.json"
lock = tmp_path / "package-lock.json"
package_json.write_bytes(b'{"name":"demo"}\n')
lock.write_bytes(b'{"lockfileVersion":3}\n')
snapshot = _manifest_snapshot(tmp_path)
package_json.write_bytes(b'{"name":"changed"}\n')
lock.unlink()
shrink = tmp_path / "npm-shrinkwrap.json"
shrink.write_text("{}\n", encoding="utf-8")
_restore_manifests(snapshot)
assert package_json.read_bytes() == b'{"name":"demo"}\n'
assert lock.read_bytes() == b'{"lockfileVersion":3}\n'
assert not shrink.exists()
def test_python_project_uses_pytest_and_coverage_lcov(tmp_path):
_write(tmp_path, "pyproject.toml", "[tool.pytest.ini_options]\ntestpaths = ['tests']\n")
source = _write(tmp_path, "src/demo/box.py", "def choose(x):\n return x\n")
_write(tmp_path, "tests/test_box.py", "def test_choose():\n assert True\n")
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
assert uses_pytest(tmp_path)
assert python_sources(tmp_path, [source, tmp_path / "tests" / "test_box.py"]) == "src"
assert is_test_file(tmp_path / "tests" / "test_box.py")
data = tmp_path / "target" / "coverage" / "python" / ".coverage"
report = data.parent / "lcov.info"
run, lcov = python_coverage_commands("python3", "pytest", data, report, "src")
assert run == (
f"python3 -m coverage run --data-file={data} --source=src -m pytest"
)
assert lcov == f"python3 -m coverage lcov --data-file={data} -o {report}"
def test_python_package_without_pytest_uses_unittest(tmp_path):
_write(tmp_path, "setup.py", "from setuptools import setup\nsetup(name='demo')\n")
source = _write(tmp_path, "demo/app.py", "def run():\n return 1\n")
assert not uses_pytest(tmp_path)
run, _lcov = python_coverage_commands("python3", "unittest", Path("data"), Path("out"), "demo")
assert "-m unittest discover -s ." in run
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
def test_rust_lcov_path_is_absolute():
report = Path("/tmp/bookwriter/target/coverage/rust/src-tauri/lcov.info")
command = rust_coverage_command("llvm-cov", report)
assert command == f"cargo llvm-cov --lcov --output-path {report}"
def test_vitest_version_and_provider_install(tmp_path, monkeypatch):
assert _vitest_version(tmp_path) is None
_write(tmp_path, "node_modules/vitest/package.json", "{not json")
assert _vitest_version(tmp_path) is None
_write(tmp_path, "node_modules/vitest/package.json", '{"version": "4.1.11"}\n')
assert _vitest_version(tmp_path) == "4.1.11"
provider = tmp_path / "node_modules" / "@vitest" / "coverage-v8"
provider.mkdir(parents=True)
assert _ensure_vitest_coverage(tmp_path) is True
provider.rmdir()
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 1)
assert _ensure_vitest_coverage(tmp_path) is False
def test_clean_clojure_keeps_other_language_reports(tmp_path):
kept = tmp_path / "target" / "coverage" / "python"
kept.mkdir(parents=True)
(kept / "lcov.info").write_text("keep", encoding="utf-8")
stale = tmp_path / "target" / "coverage" / "lcov.info"
stale.write_text("old", encoding="utf-8")
_clean_clojure(tmp_path)
assert (kept / "lcov.info").is_file()
assert not stale.exists()
def test_python_executable_prefers_a_project_venv(tmp_path):
binary = tmp_path / ".venv" / "bin" / "python"
binary.parent.mkdir(parents=True)
binary.write_text("", encoding="utf-8")
assert _python_executable(tmp_path) == str(binary)
def test_rust_kind_installs_llvm_cov_when_it_is_missing(monkeypatch):
installed = {"llvm": False}
def which(name):
if name == "cargo-llvm-cov" and installed["llvm"]:
return "/bin/cargo-llvm-cov"
if name == "rustup":
return "/bin/rustup"
return None
def shell(command, cwd):
if "cargo install" in command:
installed["llvm"] = True
return 0
monkeypatch.setattr("crapper.runners.shutil.which", which)
monkeypatch.setattr("crapper.runners.run_shell", shell)
assert _rust_kind() == "llvm-cov"
installed["llvm"] = False
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 1)
assert _rust_kind() is None
def test_coverage_commands_are_issued_per_language(tmp_path, monkeypatch):
commands: list[str] = []
def shell(command, cwd):
commands.append(command)
if "--data-file=" in command:
data = Path(command.split("--data-file=", 1)[1].split()[0])
data.parent.mkdir(parents=True, exist_ok=True)
data.write_text("x", encoding="utf-8")
return 1
if command.startswith("clj") or command.startswith("mvn") or command.startswith("go "):
return 1
if "vitest" in command or command.startswith("cargo "):
return 1
return 0
monkeypatch.setattr("crapper.runners.run_shell", shell)
monkeypatch.setattr(
"crapper.runners.shutil.which",
lambda name: "/bin/cargo-llvm-cov" if name == "cargo-llvm-cov" else None,
)
_write(tmp_path, "deps.edn", "{}\n")
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n(defn x [])\n")
_write(tmp_path, "pom.xml", "<project></project>\n")
_write(tmp_path, "src/Board.java", "class Board { int place(){ return 1; } }\n")
(tmp_path / "target" / "jacoco.exec").parent.mkdir(parents=True)
(tmp_path / "target" / "jacoco.exec").write_text("x", encoding="utf-8")
_write(tmp_path, "go.mod", "module example.com/demo\n")
_write(tmp_path, "board.go", "package demo\nfunc Place() int { return 1 }\n")
profile = tmp_path / "target" / "coverage" / "go" / "coverage.out"
profile.parent.mkdir(parents=True)
profile.write_text("old", encoding="utf-8")
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "vitest run"}}))
_write(tmp_path, "src/ui.ts", "export function view(){ return 1 }\n")
(tmp_path / "node_modules" / "@vitest" / "coverage-v8").mkdir(parents=True)
_write(tmp_path, "pyproject.toml", "[tool.pytest.ini_options]\n")
_write(tmp_path, "src/app.py", "def run():\n return 1\n")
_write(tmp_path, "Cargo.toml", '[package]\nname = "demo"\nversion = "0.1.0"\n')
_write(tmp_path, "src/lib.rs", "pub fn open() {}\n")
files = [
tmp_path / "src/demo/core.clj",
tmp_path / "src/Board.java",
tmp_path / "board.go",
tmp_path / "src/ui.ts",
tmp_path / "src/app.py",
tmp_path / "src/lib.rs",
]
run_coverage(tmp_path, files, None)
text = "\n".join(commands)
assert "clj -M:cov --lcov" in text
assert "clj -M:cov\n" in text or text.endswith("clj -M:cov") or "clj -M:cov" in text
assert "mvn " in text
assert "go test" in text
assert "vitest" in text
assert "coverage run" in text
assert "cargo llvm-cov" in text
run_coverage(tmp_path, files, "echo custom")
assert commands[-1] == "echo custom"
def test_missing_build_files_skip_that_language(tmp_path, monkeypatch):
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: 0)
files = [
_write(tmp_path, "src/App.java", "class App {}\n"),
_write(tmp_path, "main.go", "package main\n"),
_write(tmp_path, "src/app.ts", "export const n = 1\n"),
_write(tmp_path, "src/lib.rs", "pub fn open() {}\n"),
]
run_coverage(tmp_path, files, None)
+95
View File
@@ -0,0 +1,95 @@
from crapper.languages.rust import functions_in_source
SOURCE = """
pub fn choose(x: i32) -> i32 {
if x > 0 && x < 10 {
return 1;
}
match x {
1 => 1,
2 => 2,
_ => 0,
}
}
impl Sample {
pub fn run(&self) -> i32 {
if self.ok { 1 } else { 0 }
}
}
impl Trait for Sample {
fn draw(&mut self) {}
}
mod tests {
fn hidden() {
if true {}
}
}
fn outer() {
fn inner() {
if true {}
}
}
"""
def test_functions_methods_and_skipped_tests(tmp_path):
(tmp_path / "Cargo.toml").write_text(
'[package]\nname = "demo-game"\nversion = "0.1.0"\n',
encoding="utf-8",
)
path = tmp_path / "src" / "lib.rs"
path.parent.mkdir()
path.write_text(SOURCE, encoding="utf-8")
functions = functions_in_source(SOURCE, "src/lib.rs", str(tmp_path))
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo_game", "choose", 6),
("demo_game::Sample", "run", 2),
("demo_game::Sample", "draw", 1),
("demo_game", "outer", 2),
]
def test_impl_for_a_nested_generic_type(tmp_path):
(tmp_path / "Cargo.toml").write_text(
'[package]\nname = "demo"\nversion = "0.1.0"\n',
encoding="utf-8",
)
source = "impl Trait for Vec<crate::Sample> {\n fn draw(&self) {}\n}\n"
functions = functions_in_source(source, str(tmp_path / "src" / "lib.rs"), str(tmp_path))
assert functions[0].name == "draw"
assert functions[0].namespace.endswith("Vec") or "Sample" in functions[0].namespace
def test_module_paths_outside_the_usual_layout(tmp_path):
(tmp_path / "Cargo.toml").write_text(
'[package]\nname = "demo"\nversion = "0.1.0"\n',
encoding="utf-8",
)
mod = "pub fn open() {}\n"
binary = "pub fn main_ok() {}\n"
functions_in_source(mod, str(tmp_path / "src" / "game" / "mod.rs"), str(tmp_path))
functions = functions_in_source(binary, str(tmp_path / "src" / "bin" / "tool.rs"), str(tmp_path))
assert functions[0].namespace == "tool"
outside = functions_in_source("pub fn loose() {}\n", "/tmp/loose.rs", str(tmp_path))
assert outside[0].name == "loose"
bare = functions_in_source("pub fn bare() {}\n", str(tmp_path / "notes.rs"), None)
assert bare[0].name == "bare"
def test_nested_module_path(tmp_path):
(tmp_path / "Cargo.toml").write_text(
'[package]\nname = "demo"\nversion = "0.1.0"\n',
encoding="utf-8",
)
path = tmp_path / "src" / "game" / "board.rs"
path.parent.mkdir(parents=True)
source = "pub fn place() -> i32 { if ready { 1 } else { 0 } }\n"
path.write_text(source, encoding="utf-8")
functions = functions_in_source(source, "src/game/board.rs", str(tmp_path))
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo::game::board", "place", 2)
]
+53
View File
@@ -0,0 +1,53 @@
from crapper.languages.typescript import functions_in_source
def test_function_class_method_and_arrow_are_separate_entries():
source = """
export function choose(x: number): number {
if (x > 0 && x < 10) return 1;
for (let i = 0; i < x; i++) {
if (i === 2) return i;
}
try {
return x;
} catch (e) {
return 0;
}
return x > 0 ? 1 : 0;
}
export class Box {
open(flag?: boolean): number {
return flag ? 1 : 0;
}
}
const arrow = (n: number) => (n > 0 ? n : 0);
function outer() {
function inner(n: number) {
if (n) return 1;
return 0;
}
return inner(1);
}
"""
functions = functions_in_source(source, "src/demo/box.ts", "/proj")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("demo.box", "choose", 7),
("demo.box.Box", "open", 2),
("demo.box", "arrow", 2),
("demo.box", "outer", 2),
]
def test_tsx_counts_jsx_logic():
source = """
export function View(ok: boolean, ready: boolean) {
return ok && ready ? 1 : 0;
}
"""
functions = functions_in_source(source, "src/ui/view.tsx", "/proj")
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
("ui.view", "View", 3)
]