mirror of
https://github.com/unclebob/crapper.git
synced 2026-11-06 18:13:50 +00:00
Count ?? and ?., Express handlers, and JavaScript files, and score missing coverage as 0%.
This commit is contained in:
@@ -42,7 +42,7 @@ The first run creates `.venv` and installs the tool. From a project root it walk
|
||||
:crap 3.1}]}
|
||||
```
|
||||
|
||||
uml-viewer groups entries by `:namespace` and joins each operation on `:name`. `nil` coverage means the function was found but no coverage data applied, and CRAP is `nil` too.
|
||||
uml-viewer groups entries by `:namespace` and joins each operation on `:name`. `nil` coverage is what `--no-coverage` writes: complexity only, and CRAP is `nil` too. A function the coverage report does not mention scores 0%.
|
||||
|
||||
| Language | `:namespace` | `:name` |
|
||||
| --- | --- | --- |
|
||||
@@ -63,7 +63,7 @@ Java follows crap4java: `if`, loops, `catch`, `?:`, each `switch` label, and `&&
|
||||
|
||||
Go follows crap4go: `if`, `for`, `range`, each `switch` and `select` clause, and `&&` / `||`. Coverage is `go test -coverprofile`.
|
||||
|
||||
TypeScript and TSX use the same structural decisions as Java, including `&&` inside JSX. Top-level functions, class methods, and top-level arrow functions are entries. Nested callbacks stay inside the enclosing function. Coverage is LCOV. A `coverage` script is used as-is. A Vitest project runs `vitest --coverage` (installing `@vitest/coverage-v8` into `node_modules` when it is missing, without editing `package.json`). Other test scripts are wrapped in `c8`.
|
||||
TypeScript, TSX, and JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`) use the same structural decisions as Java, including `&&` inside JSX, plus `??` and `?.`. Top-level functions, class methods, and top-level arrow functions are entries. An inline Express callback — `.get`, `.post`, `.put`, `.patch`, `.delete`, `.head`, `.options`, `.all`, `.use`, and `.route(path).get(...)` — is its own entry, named `GET /users`. A second callback on that same route is `GET /users#2`. Its decisions are not also charged to the enclosing function. Other nested callbacks stay inside the enclosing function. Coverage is LCOV. When the report has branch records (`BRDA`) inside a function, the score uses those branches; a function with no branches uses line hits. A `coverage` script is used as-is. A Vitest project runs `vitest --coverage` (installing `@vitest/coverage-v8` into `node_modules` when it is missing, without editing `package.json`). Other test scripts are wrapped in `c8`.
|
||||
|
||||
Rust counts `if`, loops, each `match` arm, `?`, and `&&` / `||`. `mod tests` is skipped. Coverage is LCOV from `cargo llvm-cov` or `cargo tarpaulin`, run in the nearest directory that contains `Cargo.toml`. When neither tool is installed, the run installs `cargo-llvm-cov`.
|
||||
|
||||
@@ -82,7 +82,7 @@ By default a run deletes the previous report for each language it is about to me
|
||||
| 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` |
|
||||
|
||||
A missing tool or a failed test run leaves that language at N/A and still writes the snapshot. Pass `--coverage-command` to replace those defaults with one command of your own.
|
||||
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.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
+18
-3
@@ -6,7 +6,23 @@ from crapper.coverage import CoverageBundle
|
||||
from crapper.crap import make_entry, sort_entries
|
||||
from crapper.discover import language_of
|
||||
from crapper.languages import functions_in_file
|
||||
from crapper.model import Entry
|
||||
from crapper.model import Entry, Function
|
||||
|
||||
|
||||
def _coverage(bundle: CoverageBundle | None, function: Function) -> float | None:
|
||||
"""Percentage for one function.
|
||||
|
||||
None means coverage was not requested (`--no-coverage`). A function the
|
||||
report does not mention scores 0%, so it sorts with the other scores
|
||||
instead of sinking to the bottom as N/A.
|
||||
"""
|
||||
|
||||
if bundle is None:
|
||||
return None
|
||||
found = bundle.percent_for(function)
|
||||
if found is None:
|
||||
return 0.0
|
||||
return found
|
||||
|
||||
|
||||
def analyze_files(
|
||||
@@ -27,6 +43,5 @@ def analyze_files(
|
||||
except ValueError:
|
||||
relative = file.as_posix()
|
||||
for function in functions_in_file(language, source, relative, str(root)):
|
||||
coverage = None if bundle is None else bundle.percent_for(function)
|
||||
entries.append(make_entry(function, coverage))
|
||||
entries.append(make_entry(function, _coverage(bundle, function)))
|
||||
return sort_entries(entries)
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ CRAP = CC² × (1 − coverage)³ + CC. Writes .metrics/crap.edn for uml-viewer
|
||||
and prints a report sorted worst first.
|
||||
|
||||
Languages: Clojure (.clj .cljc .cljs .bb), Java (.java), Go (.go),
|
||||
TypeScript (.ts .tsx .mts .cts), Rust (.rs), Python (.py).
|
||||
TypeScript and JavaScript (.ts .tsx .mts .cts .js .jsx .mjs .cjs),
|
||||
Rust (.rs), Python (.py).
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit.
|
||||
|
||||
+66
-10
@@ -1,8 +1,9 @@
|
||||
"""Load Cloverage, LCOV, JaCoCo, and Go coverage profiles.
|
||||
|
||||
Clojure prefers Cloverage's per-line form counts, then LCOV. Java uses JaCoCo
|
||||
instruction counters. Go uses statement profiles. TypeScript and Rust use LCOV
|
||||
line hits. A file with no coverage data scores N/A rather than 0%.
|
||||
instruction counters. Go uses statement profiles. 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%.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,9 +20,23 @@ _SPAN = re.compile(
|
||||
r'<span[^>]*title="(\d+) out of (\d+) forms covered"[^>]*>\s*(\d+) '
|
||||
)
|
||||
_LCOV_DA = re.compile(r"DA:(\d+),(\d+)")
|
||||
_LCOV_BRDA = re.compile(r"BRDA:(\d+),([^,]*),([^,]*),(-|\d+)")
|
||||
_DOCTYPE = re.compile(r"<!DOCTYPE[^>]*>", re.IGNORECASE)
|
||||
|
||||
|
||||
class FileCoverage(dict):
|
||||
"""Line hits for one file, plus branch hits keyed by line.
|
||||
|
||||
The mapping itself is `line → (covered, total)` from `DA` records, so
|
||||
existing callers can keep indexing it. `branches` aggregates each `BRDA`
|
||||
record on that line as one branch.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.branches: dict[int, tuple[int, int]] = {}
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
text = unquote(path).replace("\\", "/")
|
||||
if text.startswith("file:"):
|
||||
@@ -90,32 +105,69 @@ def parse_form_coverage(html: str) -> dict[int, tuple[int, int]]:
|
||||
return found
|
||||
|
||||
|
||||
def parse_lcov(text: str) -> dict[str, dict[int, tuple[int, int]]]:
|
||||
out: dict[str, dict[int, tuple[int, int]]] = {}
|
||||
def _branch_hit(taken: str) -> bool:
|
||||
if taken == "-":
|
||||
return False
|
||||
try:
|
||||
return int(taken) > 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _add_branch(record: FileCoverage, line: int, taken: str) -> None:
|
||||
covered, total = record.branches.get(line, (0, 0))
|
||||
record.branches[line] = (covered + (1 if _branch_hit(taken) else 0), total + 1)
|
||||
|
||||
|
||||
def parse_lcov(text: str) -> dict[str, FileCoverage]:
|
||||
out: dict[str, FileCoverage] = {}
|
||||
current_file = None
|
||||
current: dict[int, tuple[int, int]] = {}
|
||||
current = FileCoverage()
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("SF:"):
|
||||
if current_file is not None:
|
||||
out[current_file] = current
|
||||
current_file = line[3:]
|
||||
current = {}
|
||||
current = FileCoverage()
|
||||
elif line == "end_of_record":
|
||||
if current_file is not None:
|
||||
out[current_file] = current
|
||||
current_file = None
|
||||
current = {}
|
||||
current = FileCoverage()
|
||||
elif current_file is not None:
|
||||
match = _LCOV_DA.match(line)
|
||||
if match:
|
||||
hits = int(match.group(2))
|
||||
current[int(match.group(1))] = (1 if hits > 0 else 0, 1)
|
||||
continue
|
||||
branch = _LCOV_BRDA.match(line)
|
||||
if branch:
|
||||
_add_branch(current, int(branch.group(1)), branch.group(4))
|
||||
if current_file is not None:
|
||||
out[current_file] = current
|
||||
return out
|
||||
|
||||
|
||||
def branch_percent(record, start: int, end: int) -> float | None:
|
||||
"""Branch coverage for a function, or None when that span has no branches."""
|
||||
|
||||
branches = getattr(record, "branches", None)
|
||||
if not branches:
|
||||
return None
|
||||
covered = 0
|
||||
total = 0
|
||||
for line in range(start, end + 1):
|
||||
entry = branches.get(line)
|
||||
if entry is None:
|
||||
continue
|
||||
covered += entry[0]
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
return None
|
||||
return coverage_percent(covered, total)
|
||||
|
||||
|
||||
def parse_go_profile(text: str) -> dict[str, list[tuple[int, int, int, int]]]:
|
||||
"""Map file → (start_line, end_line, statements, hits)."""
|
||||
|
||||
@@ -258,9 +310,13 @@ class CoverageBundle:
|
||||
html = self._html_lines(function.path)
|
||||
if html is not None:
|
||||
return percent_for_range(html, function.start_line, function.end_line)
|
||||
return percent_for_range(
|
||||
self._lcov_lines(function.path), function.start_line, function.end_line
|
||||
)
|
||||
record = self._lcov_lines(function.path)
|
||||
if record is None:
|
||||
return None
|
||||
branched = branch_percent(record, function.start_line, function.end_line)
|
||||
if branched is not None:
|
||||
return branched
|
||||
return percent_for_range(record, function.start_line, function.end_line)
|
||||
|
||||
def _html_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
|
||||
return _lookup(self.form_html, path)
|
||||
|
||||
@@ -14,6 +14,10 @@ EXTENSIONS = {
|
||||
".tsx": "typescript",
|
||||
".mts": "typescript",
|
||||
".cts": "typescript",
|
||||
".js": "typescript",
|
||||
".jsx": "typescript",
|
||||
".mjs": "typescript",
|
||||
".cjs": "typescript",
|
||||
".rs": "rust",
|
||||
".py": "python",
|
||||
}
|
||||
@@ -64,6 +68,14 @@ def is_test_file(path: str | Path) -> bool:
|
||||
".spec.tsx",
|
||||
".test.mts",
|
||||
".spec.mts",
|
||||
".test.js",
|
||||
".spec.js",
|
||||
".test.jsx",
|
||||
".spec.jsx",
|
||||
".test.mjs",
|
||||
".spec.mjs",
|
||||
".test.cjs",
|
||||
".spec.cjs",
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""TypeScript and TSX functions, methods, and cyclomatic complexity.
|
||||
"""TypeScript, TSX, and JavaScript functions, methods, and complexity.
|
||||
|
||||
Decision points follow the same structural rule as crap4java: `if`, loops,
|
||||
`catch`, `?:`, each `switch` case (including `default`), and `&&` / `||`.
|
||||
Top-level functions and class methods are entries. Callbacks stay inside the
|
||||
enclosing function. A class method's namespace is `module.Class`, which is
|
||||
the uml-viewer class key.
|
||||
`catch`, `?:`, each `switch` case (including `default`), `&&` / `||`, and the
|
||||
nullish operators `??` and `?.`. Top-level functions and class methods are
|
||||
entries. An inline Express route callback (`app.get("/users", handler)` and
|
||||
the same methods on a router, including `use` and `route().get`) is its own
|
||||
entry, named `GET /users`. Other nested callbacks stay inside the enclosing
|
||||
function. A class method's namespace is `module.Class`, which is the
|
||||
uml-viewer class key. `.js`, `.mjs`, `.cjs`, and `.jsx` use the JavaScript
|
||||
grammar and these same rules.
|
||||
"""
|
||||
|
||||
from crapper.languages.treesitter import (
|
||||
@@ -42,10 +46,32 @@ _FUNCTION_TYPES = {
|
||||
"arrow_function",
|
||||
"function_expression",
|
||||
}
|
||||
_ROUTE_METHODS = {
|
||||
"get",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
"all",
|
||||
"use",
|
||||
}
|
||||
_CALLBACKS = {"arrow_function", "function_expression"}
|
||||
_JS_SUFFIXES = (".jsx", ".mjs", ".cjs", ".js")
|
||||
|
||||
|
||||
def _is_decision(node) -> bool:
|
||||
return node.type in _DECISIONS or binary_logic(node)
|
||||
if node.type in _DECISIONS or binary_logic(node):
|
||||
return True
|
||||
if node.type == "binary_expression" and any(child.type == "??" for child in node.children):
|
||||
return True
|
||||
# `a?.b` and `a?.[0]` wrap `?.` in optional_chain. `a?.()` is a bare `?.`
|
||||
# in TypeScript and an optional_chain in JavaScript. Count the wrapper
|
||||
# once, and a bare token only when nothing wraps it.
|
||||
if node.type == "optional_chain":
|
||||
return True
|
||||
return node.type == "?." and (node.parent is None or node.parent.type != "optional_chain")
|
||||
|
||||
|
||||
def _skip(node) -> bool:
|
||||
@@ -55,6 +81,8 @@ def _skip(node) -> bool:
|
||||
def _grammar(path: str) -> str:
|
||||
if path.endswith(".tsx"):
|
||||
return "tsx"
|
||||
if path.endswith(_JS_SUFFIXES):
|
||||
return "javascript"
|
||||
return "typescript"
|
||||
|
||||
|
||||
@@ -69,7 +97,7 @@ def _module_namespace(path: str, source_root: str | None) -> str:
|
||||
relative = relative[4:]
|
||||
elif "/src/" in relative:
|
||||
relative = relative.split("/src/", 1)[1]
|
||||
for suffix in (".tsx", ".mts", ".cts", ".ts"):
|
||||
for suffix in (".tsx", ".mts", ".cts", ".jsx", ".mjs", ".cjs", ".ts", ".js"):
|
||||
if relative.endswith(suffix):
|
||||
relative = relative[: -len(suffix)]
|
||||
break
|
||||
@@ -102,8 +130,129 @@ def _has_body(node) -> bool:
|
||||
return child_of_type(node, "statement_block") is not None
|
||||
|
||||
|
||||
def _append(found: list[Function], **kwargs) -> None:
|
||||
found.append(Function(**kwargs))
|
||||
def _expression(node):
|
||||
current = node
|
||||
while current is not None and current.type == "parenthesized_expression":
|
||||
current = next(
|
||||
(child for child in current.children if child.type not in {"(", ")"}),
|
||||
None,
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
def _call_callee(call):
|
||||
for child in call.children:
|
||||
if child.type != "arguments":
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _property_name(data: bytes, node) -> str | None:
|
||||
if node is None or node.type != "member_expression":
|
||||
return None
|
||||
ident = child_of_type(node, "property_identifier")
|
||||
if ident is None:
|
||||
return None
|
||||
return node_text(data, ident)
|
||||
|
||||
|
||||
def _route_method(data: bytes, call) -> str | None:
|
||||
name = _property_name(data, _call_callee(call))
|
||||
if name is None:
|
||||
return None
|
||||
lowered = name.lower()
|
||||
if lowered in _ROUTE_METHODS:
|
||||
return lowered
|
||||
return None
|
||||
|
||||
|
||||
def _literal_text(data: bytes, node) -> str | None:
|
||||
if node.type == "string":
|
||||
return "".join(
|
||||
node_text(data, child) for child in node.children if child.type == "string_fragment"
|
||||
)
|
||||
if node.type == "template_string":
|
||||
text = node_text(data, node)
|
||||
if len(text) >= 2 and text[0] == "`" and text[-1] == "`":
|
||||
return text[1:-1]
|
||||
return None
|
||||
|
||||
|
||||
def _string_argument(data: bytes, call) -> str | None:
|
||||
arguments = child_of_type(call, "arguments")
|
||||
if arguments is None:
|
||||
return None
|
||||
for child in arguments.children:
|
||||
expr = _expression(child)
|
||||
if expr is None:
|
||||
continue
|
||||
text = _literal_text(data, expr)
|
||||
if text is not None:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _path_from_route(data: bytes, node) -> str | None:
|
||||
"""Path from a preceding `.route("/path")` in a chained call."""
|
||||
|
||||
current = node
|
||||
while current is not None:
|
||||
if current.type == "member_expression":
|
||||
current = current.children[0] if current.children else None
|
||||
continue
|
||||
if current.type != "call_expression":
|
||||
return None
|
||||
callee = _call_callee(current)
|
||||
if _property_name(data, callee) == "route":
|
||||
return _string_argument(data, current)
|
||||
current = callee
|
||||
return None
|
||||
|
||||
|
||||
def _callbacks(call) -> list:
|
||||
arguments = child_of_type(call, "arguments")
|
||||
if arguments is None:
|
||||
return []
|
||||
found = []
|
||||
for child in arguments.children:
|
||||
expr = _expression(child)
|
||||
if expr is not None and expr.type in _CALLBACKS:
|
||||
found.append(expr)
|
||||
return found
|
||||
|
||||
|
||||
def _is_route_callback(data: bytes, node) -> bool:
|
||||
if node.type not in _CALLBACKS:
|
||||
return False
|
||||
current = node.parent
|
||||
while current is not None and current.type == "parenthesized_expression":
|
||||
current = current.parent
|
||||
if current is None or current.type != "arguments":
|
||||
return False
|
||||
call = current.parent
|
||||
if call is None or call.type != "call_expression":
|
||||
return False
|
||||
return _route_method(data, call) is not None
|
||||
|
||||
|
||||
def _complexity(data: bytes, node) -> int:
|
||||
root_id = node.id
|
||||
|
||||
def skip(current) -> bool:
|
||||
if current.id == root_id:
|
||||
return False
|
||||
return _skip(current) or _is_route_callback(data, current)
|
||||
|
||||
return complexity(node, _is_decision, skip)
|
||||
|
||||
|
||||
def _append(found: list, node, **kwargs) -> None:
|
||||
found.append(
|
||||
(
|
||||
node.start_byte,
|
||||
Function(start_byte=node.start_byte, end_byte=node.end_byte, **kwargs),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_function(found, data, node, module, path) -> None:
|
||||
@@ -114,9 +263,10 @@ def _append_function(found, data, node, module, path) -> None:
|
||||
return
|
||||
_append(
|
||||
found,
|
||||
node,
|
||||
name=node_text(data, ident),
|
||||
namespace=module,
|
||||
complexity=complexity(node, _is_decision, _skip),
|
||||
complexity=_complexity(data, node),
|
||||
start_line=start_line(node),
|
||||
end_line=end_line(node),
|
||||
path=path,
|
||||
@@ -133,9 +283,10 @@ def _append_method(found, data, node, module, path) -> None:
|
||||
return
|
||||
_append(
|
||||
found,
|
||||
node,
|
||||
name=node_text(data, ident),
|
||||
namespace=f"{module}.{'.'.join(classes)}",
|
||||
complexity=complexity(node, _is_decision, _skip),
|
||||
complexity=_complexity(data, node),
|
||||
start_line=start_line(node),
|
||||
end_line=end_line(node),
|
||||
path=path,
|
||||
@@ -155,9 +306,10 @@ def _append_arrows(found, data, node, module, path) -> None:
|
||||
continue
|
||||
_append(
|
||||
found,
|
||||
declarator,
|
||||
name=node_text(data, ident),
|
||||
namespace=module,
|
||||
complexity=complexity(value, _is_decision, _skip),
|
||||
complexity=_complexity(data, value),
|
||||
start_line=start_line(declarator),
|
||||
end_line=end_line(declarator),
|
||||
path=path,
|
||||
@@ -165,12 +317,62 @@ def _append_arrows(found, data, node, module, path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _route_label(method: str, path: str | None) -> str:
|
||||
label = method.upper()
|
||||
if path:
|
||||
return f"{label} {path}"
|
||||
return label
|
||||
|
||||
|
||||
def _claim(used: set[str], name: str) -> str:
|
||||
if name not in used:
|
||||
used.add(name)
|
||||
return name
|
||||
number = 2
|
||||
while f"{name}#{number}" in used:
|
||||
number += 1
|
||||
chosen = f"{name}#{number}"
|
||||
used.add(chosen)
|
||||
return chosen
|
||||
|
||||
|
||||
def _collect_routes(routes: list, data: bytes, call) -> None:
|
||||
method = _route_method(data, call)
|
||||
if method is None:
|
||||
return
|
||||
callbacks = _callbacks(call)
|
||||
if not callbacks:
|
||||
return
|
||||
route = _string_argument(data, call)
|
||||
if not route:
|
||||
route = _path_from_route(data, _call_callee(call))
|
||||
for callback in callbacks:
|
||||
routes.append((callback.start_byte, callback, method, route))
|
||||
|
||||
|
||||
def _append_routes(found, routes, data, module, path) -> None:
|
||||
used: set[str] = set()
|
||||
for _start, callback, method, route in sorted(routes, key=lambda item: item[0]):
|
||||
_append(
|
||||
found,
|
||||
callback,
|
||||
name=_claim(used, _route_label(method, route)),
|
||||
namespace=module,
|
||||
complexity=_complexity(data, callback),
|
||||
start_line=start_line(callback),
|
||||
end_line=end_line(callback),
|
||||
path=path,
|
||||
language="typescript",
|
||||
)
|
||||
|
||||
|
||||
def functions_in_source(
|
||||
source: str, path: str, source_root: str | None = None
|
||||
) -> list[Function]:
|
||||
data, tree = parse(source, _grammar(path))
|
||||
module = _module_namespace(path, source_root)
|
||||
found: list[Function] = []
|
||||
found: list[tuple[int, Function]] = []
|
||||
routes: list[tuple[int, object, str, str | None]] = []
|
||||
for node in descendants(tree.root_node):
|
||||
if node.type == "function_declaration":
|
||||
_append_function(found, data, node, module, path)
|
||||
@@ -178,7 +380,11 @@ def functions_in_source(
|
||||
_append_method(found, data, node, module, path)
|
||||
elif node.type == "lexical_declaration":
|
||||
_append_arrows(found, data, node, module, path)
|
||||
return found
|
||||
elif node.type == "call_expression":
|
||||
_collect_routes(routes, data, node)
|
||||
_append_routes(found, routes, data, module, path)
|
||||
found.sort(key=lambda item: item[0])
|
||||
return [function for _, function in found]
|
||||
|
||||
|
||||
class TypeScript(Language):
|
||||
|
||||
@@ -31,7 +31,8 @@ def render_edn(entries: list[Entry]) -> str:
|
||||
"""EDN map `{:entries [...]}` with crap4clj's keys, in crap4clj's order.
|
||||
|
||||
uml-viewer groups this file by `:namespace` and joins each operation on
|
||||
`:name`. Coverage is a percentage. `nil` coverage and CRAP mean N/A.
|
||||
`:name`. Coverage is a percentage. `nil` coverage and CRAP mean `--no-coverage`.
|
||||
A function absent from the report is `0.0`, not `nil`.
|
||||
"""
|
||||
|
||||
rows = []
|
||||
|
||||
@@ -13,6 +13,10 @@ class Function:
|
||||
path: str
|
||||
language: str
|
||||
jacoco_class: str | None = None
|
||||
# UTF-8 byte span of the function. -1 when the language does not record it.
|
||||
# Mutator uses the span to give a nested handler the sites inside it.
|
||||
start_byte: int = -1
|
||||
end_byte: int = -1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
"""Run each language's coverage tool, then leave the reports for the loader.
|
||||
|
||||
Failures are reported and do not stop analysis. A language with no coverage
|
||||
tool, or a failed run, keeps N/A coverage for its functions.
|
||||
tool, or a failed run, scores its functions at 0%.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -139,7 +139,7 @@ def _ensure_vitest_coverage(package: Path) -> bool:
|
||||
_restore_manifests(snapshot)
|
||||
ready = code == 0 and (package / "node_modules" / "@vitest" / "coverage-v8").is_dir()
|
||||
if not ready:
|
||||
_warn("Vitest's coverage provider is missing. TypeScript coverage will be N/A.")
|
||||
_warn("Vitest's coverage provider is missing. TypeScript coverage will score 0%.")
|
||||
return ready
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ def _rust_kind() -> str | None:
|
||||
code = run_shell("cargo install cargo-llvm-cov --locked", Path.home())
|
||||
if code == 0 and shutil.which("cargo-llvm-cov"):
|
||||
return "llvm-cov"
|
||||
_warn("Rust coverage will be N/A.")
|
||||
_warn("Rust coverage will score 0%.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ def _cover_clojure(root: Path) -> None:
|
||||
_warn("clj -M:cov --lcov failed; retrying without --lcov.")
|
||||
code = run_shell("clj -M:cov", root)
|
||||
if code != 0:
|
||||
_warn(f"Clojure coverage exited {code}. Clojure coverage will be N/A.")
|
||||
_warn(f"Clojure coverage exited {code}. Clojure coverage will score 0%.")
|
||||
|
||||
|
||||
def _cover_java(root: Path, files: list[Path]) -> None:
|
||||
@@ -374,7 +374,7 @@ def _cover_java(root: Path, files: list[Path]) -> None:
|
||||
exec_file.unlink()
|
||||
code = run_shell(_MAVEN, module)
|
||||
if code != 0:
|
||||
_warn(f"Java coverage exited {code} in {module}. Java coverage will be N/A.")
|
||||
_warn(f"Java coverage exited {code} in {module}. Java coverage will score 0%.")
|
||||
|
||||
|
||||
def _cover_go(root: Path, files: list[Path]) -> None:
|
||||
@@ -389,7 +389,7 @@ def _cover_go(root: Path, files: list[Path]) -> None:
|
||||
profile.unlink()
|
||||
code = run_shell(f"go test ./... -coverprofile={profile}", module)
|
||||
if code != 0:
|
||||
_warn(f"Go coverage exited {code} in {module}. Go coverage will be N/A.")
|
||||
_warn(f"Go coverage exited {code} in {module}. Go coverage will score 0%.")
|
||||
|
||||
|
||||
def _prepare_report(report: Path) -> None:
|
||||
@@ -414,7 +414,7 @@ def _cover_typescript(root: Path, files: list[Path]) -> None:
|
||||
_prepare_report(report)
|
||||
code = run_shell(command, package)
|
||||
if code != 0:
|
||||
_warn(f"TypeScript coverage exited {code} in {package}. TypeScript coverage will be N/A.")
|
||||
_warn(f"TypeScript coverage exited {code} in {package}. TypeScript coverage will score 0%.")
|
||||
|
||||
|
||||
def _needs_vitest_provider(command: str) -> bool:
|
||||
@@ -433,11 +433,11 @@ def _python_kind(py: str, package: Path) -> str:
|
||||
def _record_python_lcov(package: Path, code: int, data_file: Path, lcov_cmd: str) -> None:
|
||||
if not data_file.exists():
|
||||
if code != 0:
|
||||
_warn(f"Python coverage exited {code} in {package}. Python coverage will be N/A.")
|
||||
_warn(f"Python coverage exited {code} in {package}. Python coverage will score 0%.")
|
||||
return
|
||||
lcov_code = run_shell(lcov_cmd, package)
|
||||
if lcov_code != 0:
|
||||
_warn(f"coverage lcov exited {lcov_code} in {package}. Python coverage will be N/A.")
|
||||
_warn(f"coverage lcov exited {lcov_code} in {package}. Python coverage will score 0%.")
|
||||
elif code != 0:
|
||||
_warn(f"Python tests exited {code} in {package}. Coverage was still recorded.")
|
||||
|
||||
@@ -447,7 +447,7 @@ def _cover_python(root: Path, files: list[Path]) -> None:
|
||||
report = _coverage_report(root, package, "python")
|
||||
py = _python_executable(package)
|
||||
if not _ensure_python_module(py, package, "coverage"):
|
||||
_warn(f"coverage is missing in {package}. Python coverage will be N/A.")
|
||||
_warn(f"coverage is missing in {package}. Python coverage will score 0%.")
|
||||
continue
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
data_file = report.parent / ".coverage"
|
||||
@@ -473,7 +473,7 @@ def _cover_rust(root: Path, files: list[Path]) -> None:
|
||||
report.unlink(missing_ok=True)
|
||||
code = run_shell(rust_coverage_command(kind, report), module)
|
||||
if code != 0:
|
||||
_warn(f"Rust coverage exited {code} in {module}. Rust coverage will be N/A.")
|
||||
_warn(f"Rust coverage exited {code} in {module}. Rust coverage will score 0%.")
|
||||
|
||||
|
||||
def run_coverage(root: Path, files: list[Path], command: str | None) -> None:
|
||||
@@ -483,7 +483,7 @@ def run_coverage(root: Path, files: list[Path], command: str | None) -> None:
|
||||
if command:
|
||||
code = run_shell(command, root)
|
||||
if code != 0:
|
||||
_warn(f"Coverage command exited {code}. Coverage may be N/A.")
|
||||
_warn(f"Coverage command exited {code}. Coverage may score 0%.")
|
||||
return
|
||||
|
||||
languages = _languages_in(files)
|
||||
|
||||
@@ -14,6 +14,10 @@ def test_language_detection():
|
||||
assert language_of("Widget.java") == "java"
|
||||
assert language_of("board.go") == "go"
|
||||
assert language_of("ui/view.tsx") == "typescript"
|
||||
assert language_of("src/app.js") == "typescript"
|
||||
assert language_of("src/app.mjs") == "typescript"
|
||||
assert language_of("src/app.cjs") == "typescript"
|
||||
assert language_of("src/app.jsx") == "typescript"
|
||||
assert language_of("src/lib.rs") == "rust"
|
||||
assert language_of("src/crapper/cli.py") == "python"
|
||||
assert language_of("types.d.ts") is None
|
||||
|
||||
@@ -42,6 +42,29 @@ def test_lcov_percentage_when_there_is_no_html():
|
||||
assert bundle.percent_for(function(path="src/missing.clj")) is None
|
||||
|
||||
|
||||
def test_lcov_branch_records_override_line_hits():
|
||||
text = "SF:src/demo/app.ts\nDA:2,1\nDA:3,1\nBRDA:3,0,0,1\nBRDA:3,0,1,-\nend_of_record\n"
|
||||
parsed = parse_lcov(text)
|
||||
assert parsed["src/demo/app.ts"][2] == (1, 1)
|
||||
assert parsed["src/demo/app.ts"].branches[3] == (1, 2)
|
||||
bundle = CoverageBundle(lcov=parsed)
|
||||
scored = function(
|
||||
path="src/demo/app.ts",
|
||||
language="typescript",
|
||||
start_line=2,
|
||||
end_line=3,
|
||||
namespace="demo.app",
|
||||
)
|
||||
assert bundle.percent_for(scored) == 50.0
|
||||
|
||||
|
||||
def test_a_branchless_span_keeps_line_coverage():
|
||||
text = "SF:src/demo/app.ts\nDA:2,1\nDA:3,0\nBRDA:8,0,0,1\nBRDA:8,0,1,0\nend_of_record\n"
|
||||
bundle = CoverageBundle(lcov=parse_lcov(text))
|
||||
scored = function(path="src/demo/app.ts", language="typescript", start_line=2, end_line=3)
|
||||
assert bundle.percent_for(scored) == 50.0
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -145,6 +145,8 @@ def test_load_bundle_on_an_empty_tree_has_no_profiles(tmp_path):
|
||||
|
||||
def test_conftest_and_a_tests_directory_are_tests(tmp_path):
|
||||
assert is_test_file("conftest.py") is True
|
||||
assert is_test_file("src/app.test.js") is True
|
||||
assert is_test_file("src/app.spec.mjs") is True
|
||||
assert is_test_file(tmp_path / "tests" / "test_board.py") is True
|
||||
source = tmp_path / "src" / "app.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
@@ -160,6 +162,24 @@ def test_conftest_and_a_tests_directory_are_tests(tmp_path):
|
||||
assert iter_source_files([source]) == [source.resolve()]
|
||||
|
||||
|
||||
def test_missing_coverage_scores_zero_and_sorts_ahead_of_a_covered_function(tmp_path):
|
||||
low = tmp_path / "src" / "low.ts"
|
||||
high = tmp_path / "src" / "high.ts"
|
||||
low.parent.mkdir(parents=True)
|
||||
body = "export function {name}(x: number) {{\n if (x) return 1;\n return 0;\n}}\n"
|
||||
low.write_text(body.format(name="low"), encoding="utf-8")
|
||||
high.write_text(body.format(name="high"), encoding="utf-8")
|
||||
bundle = CoverageBundle(lcov={"src/low.ts": {1: (1, 1), 2: (1, 1), 3: (1, 1)}})
|
||||
entries = analyze_files([low, high], tmp_path, bundle)
|
||||
assert [(entry.name, entry.coverage, entry.crap) for entry in entries] == [
|
||||
("high", 0.0, 6.0),
|
||||
("low", 100.0, 2.0),
|
||||
]
|
||||
unscored = analyze_files([high], tmp_path, None)
|
||||
assert unscored[0].coverage is None
|
||||
assert unscored[0].crap is None
|
||||
|
||||
|
||||
def test_files_outside_the_project_keep_an_absolute_path(tmp_path):
|
||||
outside = tmp_path / "board.go"
|
||||
outside.write_text("package demo\nfunc Place() int { return 1 }\n", encoding="utf-8")
|
||||
|
||||
@@ -51,3 +51,84 @@ export function View(ok: boolean, ready: boolean) {
|
||||
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
|
||||
("ui.view", "View", 3)
|
||||
]
|
||||
|
||||
|
||||
def test_nullish_and_optional_chains_are_branches():
|
||||
source = """
|
||||
export function choose(a, b, c) {
|
||||
return a ?? b?.c ?? c?.() ?? c?.[0];
|
||||
}
|
||||
export function text() {
|
||||
return "a ?? b?.c";
|
||||
}
|
||||
"""
|
||||
functions = functions_in_source(source, "src/demo/box.ts", "/proj")
|
||||
assert [(fn.name, fn.complexity) for fn in functions] == [
|
||||
("choose", 7),
|
||||
("text", 1),
|
||||
]
|
||||
|
||||
|
||||
def test_express_handlers_are_entries_and_leave_the_parent():
|
||||
source = """
|
||||
export function mount(app) {
|
||||
const extra = [1].map((n) => (n ? 1 : 0));
|
||||
app.get("/users", (req, res) => {
|
||||
if (req.query.q) return 1;
|
||||
return 0;
|
||||
});
|
||||
app.post("/users", function (req, res) {
|
||||
return req.body ?? {};
|
||||
});
|
||||
app.use((req, res, next) => next());
|
||||
app.route("/items").get((req, res) => res.send(1)).post((req, res) => res.send(2));
|
||||
return extra;
|
||||
}
|
||||
app.get("/health", async (req, res) => res.send(req.query.ok ?? "no"));
|
||||
app.get("/users", (req, res, next) => next(), (req, res) => res.send(req.id ?? 0));
|
||||
app.delete(`/users/${id}`, ((req, res) => res.send(1)));
|
||||
"""
|
||||
functions = functions_in_source(source, "src/demo/routes.ts", "/proj")
|
||||
assert [(fn.name, fn.complexity) for fn in functions] == [
|
||||
("mount", 2),
|
||||
("GET /users", 2),
|
||||
("POST /users", 2),
|
||||
("USE", 1),
|
||||
("GET /items", 1),
|
||||
("POST /items", 1),
|
||||
("GET /health", 2),
|
||||
("GET /users#2", 1),
|
||||
("GET /users#3", 2),
|
||||
("DELETE /users/${id}", 1),
|
||||
]
|
||||
assert {fn.namespace for fn in functions} == {"demo.routes"}
|
||||
|
||||
|
||||
def test_a_nested_callback_that_is_not_a_route_stays_inside_its_function():
|
||||
source = """
|
||||
function outer() {
|
||||
const values = [1].map((n) => (n ? 1 : 0));
|
||||
return values;
|
||||
}
|
||||
"""
|
||||
functions = functions_in_source(source, "src/demo/box.ts", "/proj")
|
||||
assert [(fn.name, fn.complexity) for fn in functions] == [("outer", 2)]
|
||||
|
||||
|
||||
def test_javascript_files_use_the_same_rules():
|
||||
source = """
|
||||
export function choose(a, b, c) {
|
||||
return a ?? b?.c ?? c?.() ?? c?.[0];
|
||||
}
|
||||
app.get("/users", (req, res) => req.id ?? 0);
|
||||
"""
|
||||
functions = functions_in_source(source, "src/demo/app.mjs", "/proj")
|
||||
assert [(fn.namespace, fn.name, fn.complexity) for fn in functions] == [
|
||||
("demo.app", "choose", 7),
|
||||
("demo.app", "GET /users", 2),
|
||||
]
|
||||
js = functions_in_source(source, "src/demo/app.js", "/proj")
|
||||
assert [(fn.name, fn.complexity) for fn in js] == [
|
||||
("choose", 7),
|
||||
("GET /users", 2),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user