Count ?? and ?., Express handlers, and JavaScript files, and score missing coverage as 0%.

This commit is contained in:
Robert C. Martin
2026-10-03 08:49:10 -05:00
parent b1c0fa4104
commit f0cb973110
13 changed files with 467 additions and 44 deletions
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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+)&nbsp;'
)
_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)
+12
View File
@@ -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
+220 -14
View File
@@ -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):
+2 -1
View File
@@ -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 = []
+4
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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
+23
View File
@@ -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"
+20
View File
@@ -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")
+81
View File
@@ -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),
]