Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d560a2e
Score Scala with tree-sitter and scoverage
rcardin Nov 3, 2026
829bd01
End a Scala def on its last body token
rcardin Nov 3, 2026
4d38dcb
Delete stale scoverage reports before running sbt
rcardin Nov 3, 2026
c11b6d8
Count a Scala 3 single catch case without braces
rcardin Nov 3, 2026
fc82a70
Reject Scala defs with no name after the keyword
rcardin Nov 3, 2026
58d5084
Name the given a def in a given body belongs to
rcardin Nov 3, 2026
9ca4ff0
Walk TypeScript class names with the shared ancestor_names
rcardin Nov 3, 2026
d6f23d0
Cover the Scala 2 do while decision in the score test
rcardin Nov 3, 2026
189e2e4
Test that a def in a lambda in a val is not an entry
rcardin Nov 3, 2026
6c302dd
Test that an alphanumeric infix operator is not a decision
rcardin Nov 3, 2026
4823ebd
Pin that a local class inside a def is skipped
rcardin Nov 3, 2026
6eae871
Pin the scoverage hit threshold with a statement run once
rcardin Nov 3, 2026
81fce7c
Test scoverage reports at every module depth
rcardin Nov 3, 2026
10764df
Test the Scala runner warnings and sbt directory
rcardin Nov 3, 2026
2b04495
Test that inline Rust modules extend the namespace
rcardin Nov 3, 2026
2b9b099
Kill the enclosed arrow guard mutant in TypeScript
rcardin Nov 3, 2026
26222a1
Kill the constructor mutant in Java's executable set
rcardin Nov 3, 2026
8b7742e
Document that partial function cases count in Scala
rcardin Nov 3, 2026
b17e910
Name Scala in the tree-sitter helpers docstring
rcardin Nov 3, 2026
c0c5d3b
Write per line records without a hyphen in coverage.py
rcardin Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# crapper

CRAP scores for Clojure, Java, Go, TypeScript, Rust, and Python. One run detects the language of each source file, applies that language's complexity and coverage rules, and writes the snapshot [uml-viewer](https://github.com/unclebob/uml-viewer) already reads.
CRAP scores for Clojure, Java, Go, TypeScript, Rust, Scala, and Python. One run detects the language of each source file, applies that language's complexity and coverage rules, and writes the snapshot [uml-viewer](https://github.com/unclebob/uml-viewer) already reads.

The formula is the one from [crap4clj](https://github.com/unclebob/crap4clj), [crap4java](https://github.com/unclebob/crap4java), and [crap4go](https://github.com/unclebob/crap4go):

Expand Down Expand Up @@ -52,6 +52,7 @@ uml-viewer groups entries by `:namespace` and joins each operation on `:name`. `
| TypeScript | the dotted module path, or `module.Class` | the function or method name |
| Rust | `crate::module`, or `crate::module::Type` | the function or method name |
| Python | the dotted module path, or `module.Class` | the function or method name |
| Scala | `package.Object` or `package.Outer.Inner`; `package.Object.given_Ordering_Int` inside a given with a body; the package alone for a top level def | the def name |

Rename or move is a new entry. There is no identity matching across runs.

Expand All @@ -69,6 +70,8 @@ Rust counts `if`, loops, each `match` arm, `?`, and `&&` / `||`. `mod tests` is

Python counts `if`, `elif`, `for`, `while`, `except`, each `match` case, a comprehension filter, a conditional expression, and each `and` / `or`. Nested functions stay inside the enclosing function. Coverage is LCOV from `coverage.py`, running pytest when the project uses it and `unittest` otherwise. `coverage` and `pytest` are installed into the project's interpreter when they are missing.

Scala counts `if`, `while`, `do`, `for`, a `for` guard, each `case` of a `match`, a `catch` or a partial function literal, a `case` guard, and each `&&` / `||`. A lambda and a local def stay inside the enclosing method; an anonymous class body is skipped, as in Java. An abstract def and a secondary constructor are not entries. A def in the body of a given belongs to that given, as scalac compiles it: a named given adds its name to the namespace, and an anonymous given adds the name scalac gives it, `given_` and the type with each type argument by its outer name, so `given Ordering[Int] with` is `given_Ordering_Int` and `given Map[String, List[Int]] with` is `given_Map_String_List`. Coverage is scoverage's statement report: a statement with a positive invocation count is covered, and a method is scored by the statements on its lines.

## Coverage commands

By default a run deletes the previous report for each language it is about to measure and regenerates it:
Expand All @@ -81,6 +84,7 @@ By default a run deletes the previous report for each language it is about to me
| TypeScript | `npm run coverage`, or Vitest `--coverage`, or `npx c8 ... npm test` | `coverage/lcov.info` or `target/coverage/typescript/lcov.info` |
| Rust | `cargo llvm-cov` or `cargo tarpaulin`, per Cargo package | `target/coverage/rust/lcov.info` |
| Python | `coverage run` with pytest or unittest, then `coverage lcov` | `target/coverage/python/lcov.info` |
| Scala | `sbt coverage test coverageReport`, in the nearest directory with `build.sbt` | `target/scala-*/scoverage-report/scoverage.xml`, one per sbt module |

A missing tool or a failed test run scores that language at 0% and still writes the snapshot. Pass `--coverage-command` to replace those defaults with one command of your own. `--no-coverage` is the run that leaves coverage and CRAP as N/A.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "crapper"
version = "0.1.0"
description = "CRAP scores for Clojure, Java, Go, TypeScript, Rust, and Python, in the snapshot uml-viewer reads."
description = "CRAP scores for Clojure, Java, Go, TypeScript, Rust, Scala, and Python, in the snapshot uml-viewer reads."
readme = "README.md"
license = { file = "LICENSE" }
classifiers = ["License :: OSI Approved :: Zero-Clause BSD (0BSD)"]
Expand Down
48 changes: 45 additions & 3 deletions src/crapper/coverage.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Load Cloverage, LCOV, JaCoCo, and Go coverage profiles.
"""Load Cloverage, LCOV, JaCoCo, scoverage, and Go coverage profiles.

Clojure prefers Cloverage's per-line form counts, then LCOV. Java uses JaCoCo
instruction counters. Go uses statement profiles. LCOV scores a function by
instruction counters. Go uses statement profiles. Scala uses scoverage's
statements, read into the same per line records LCOV fills. LCOV scores a function by
its BRDA branch records when it has any, and by line hits otherwise.
`percent_for` returns None when the file is absent; analysis turns that into 0%.
"""
Expand Down Expand Up @@ -418,6 +419,26 @@ def parse_jacoco_index(text: str) -> dict[str, list[JacocoMethod]]:
return found


def parse_scoverage(text: str) -> dict[str, FileCoverage]:
"""Statements per line, keyed by the absolute source path scoverage writes.

A statement is covered when its invocation count is positive. An ignored
statement is left out, as scoverage leaves it out of its own rates.
"""

root = ET.fromstring(_DOCTYPE.sub("", text))
out: dict[str, FileCoverage] = {}
for statement in root.iter("statement"):
if statement.get("ignored") == "true":
continue
record = out.setdefault(statement.get("source", ""), FileCoverage())
line = int(statement.get("line") or "0")
covered, total = record.get(line, (0, 0))
hit = 1 if int(statement.get("invocation-count") or "0") > 0 else 0
record[line] = (covered + hit, total + 1)
return out


@dataclass
class CoverageBundle:
lcov: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict)
Expand Down Expand Up @@ -543,6 +564,23 @@ def _merge_jacoco(root: Path) -> dict[str, list[JacocoMethod]]:
return merged


def scoverage_paths(root: Path) -> list[Path]:
"""sbt-scoverage writes one report per module, under that module's target."""

pattern = "target/scala-*/scoverage-report/scoverage.xml"
paths = list(root.glob(pattern))
paths.extend(root.glob(f"*/{pattern}"))
paths.extend(root.glob(f"*/*/{pattern}"))
return paths


def _merge_scoverage(root: Path) -> dict[str, FileCoverage]:
merged: dict[str, FileCoverage] = {}
for text in _merge_texts(scoverage_paths(root)):
merged.update(parse_scoverage(text))
return merged


def _merge_forms(root: Path) -> dict[str, dict[int, tuple[int, int]]]:
coverage_dir = root / "target" / "coverage"
found: dict[str, dict[int, tuple[int, int]]] = {}
Expand All @@ -561,8 +599,12 @@ def _merge_forms(root: Path) -> dict[str, dict[int, tuple[int, int]]]:
def load_bundle(root: Path) -> CoverageBundle:
go_profile = _merge_go(root)
jacoco = _merge_jacoco(root)
# scoverage statements are per line records like LCOV's, so they score
# a function the same way: by its line range.
lines = _merge_lcov(root)
lines.update(_merge_scoverage(root))
return CoverageBundle(
lcov=_merge_lcov(root),
lcov=lines,
go_profile=go_profile or None,
jacoco=jacoco or None,
form_html=_merge_forms(root),
Expand Down
1 change: 1 addition & 0 deletions src/crapper/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
".cjs": "typescript",
".rs": "rust",
".py": "python",
".scala": "scala",
}

SKIP_DIRS = {
Expand Down
2 changes: 2 additions & 0 deletions src/crapper/languages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from crapper.languages.java import functions_in_source as java_functions
from crapper.languages.python import functions_in_source as python_functions
from crapper.languages.rust import functions_in_source as rust_functions
from crapper.languages.scala import functions_in_source as scala_functions
from crapper.languages.typescript import functions_in_source as typescript_functions
from crapper.model import Function

Expand All @@ -15,6 +16,7 @@
"typescript": typescript_functions,
"python": python_functions,
"rust": rust_functions,
"scala": scala_functions,
}


Expand Down
31 changes: 7 additions & 24 deletions src/crapper/languages/java.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
import re

from crapper.languages.treesitter import (
ancestor_names,
binary_logic,
child_of_type,
complexity,
enclosed_by,
end_line,
node_text,
parse,
Expand Down Expand Up @@ -47,35 +49,16 @@ def _skip(node) -> bool:
return node.type in _NESTED_TYPES or node.type == "class_body"


def _inside_executable(node) -> bool:
current = node.parent
while current is not None:
if current.type in {
"method_declaration",
"constructor_declaration",
"lambda_expression",
}:
return True
current = current.parent
return False
_EXECUTABLE = {"method_declaration", "constructor_declaration", "lambda_expression"}


def _package_name(source: str) -> str | None:
match = _PACKAGE.search(source)
return match.group(1) if match else None


def _type_chain(data: bytes, node) -> list[str]:
names: list[str] = []
current = node.parent
while current is not None:
if current.type in _NESTED_TYPES:
ident = child_of_type(current, "identifier")
if ident is not None:
names.append(node_text(data, ident))
current = current.parent
names.reverse()
return names
def _type_name(node):
return child_of_type(node, "identifier") if node.type in _NESTED_TYPES else None


def _qualify(package: str | None, names: list[str], nested: str) -> str:
Expand All @@ -95,10 +78,10 @@ def functions_in_source(
stack = [tree.root_node]
while stack:
node = stack.pop()
if node.type == "method_declaration" and not _inside_executable(node):
if node.type == "method_declaration" and not enclosed_by(node, _EXECUTABLE):
if child_of_type(node, "block") is not None:
ident = child_of_type(node, "identifier")
names = _type_chain(data, node)
names = ancestor_names(data, node, _type_name)
if ident is not None and names:
found.append(
Function(
Expand Down
16 changes: 4 additions & 12 deletions src/crapper/languages/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

from crapper.languages.treesitter import (
ancestor_names,
child_of_type,
complexity,
descendants,
Expand Down Expand Up @@ -89,17 +90,8 @@ def _is_nested_function(node) -> bool:
return False


def _class_names(data: bytes, node) -> list[str]:
names: list[str] = []
current = node.parent
while current is not None:
if current.type == "class_definition":
ident = child_of_type(current, "identifier")
if ident is not None:
names.append(node_text(data, ident))
current = current.parent
names.reverse()
return names
def _class_name(node):
return child_of_type(node, "identifier") if node.type == "class_definition" else None


def functions_in_source(
Expand All @@ -114,7 +106,7 @@ def functions_in_source(
ident = child_of_type(node, "identifier")
if ident is None:
continue
classes = _class_names(data, node)
classes = ancestor_names(data, node, _class_name)
if classes:
namespace = ".".join([module, *classes]) if module else ".".join(classes)
else:
Expand Down
18 changes: 5 additions & 13 deletions src/crapper/languages/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from crapper.languages.treesitter import (
absolute_path as _absolute,
ancestor_names,
binary_logic,
child_of_type,
complexity,
Expand Down Expand Up @@ -47,21 +48,12 @@ def _has_block(node) -> bool:
return child_of_type(node, "block") is not None


def _ancestor_mods(data: bytes, node) -> list[str]:
names: list[str] = []
current = node.parent
while current is not None:
if current.type == "mod_item":
ident = child_of_type(current, "identifier")
if ident is not None:
names.append(node_text(data, ident))
current = current.parent
names.reverse()
return names
def _mod_name(node):
return child_of_type(node, "identifier") if node.type == "mod_item" else None


def _in_mod_named(data: bytes, node, name: str) -> bool:
return name in _ancestor_mods(data, node)
return name in ancestor_names(data, node, _mod_name)


def _first_type_name(data: bytes, node) -> str | None:
Expand Down Expand Up @@ -166,7 +158,7 @@ def _record_function(data: bytes, node, modules: list[str], path: str) -> Functi
return None
return Function(
name=node_text(data, ident),
namespace=_namespace(modules, _ancestor_mods(data, node), _impl_owner(data, node)),
namespace=_namespace(modules, ancestor_names(data, node, _mod_name), _impl_owner(data, node)),
complexity=complexity(node, _is_decision, _skip),
start_line=start_line(node),
end_line=end_line(node),
Expand Down
Loading