From f0cb97311060f1911f24010b5454004c647ca6e4 Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Sat, 3 Oct 2026 08:49:10 -0500 Subject: [PATCH] Count ?? and ?., Express handlers, and JavaScript files, and score missing coverage as 0%. --- README.md | 6 +- src/crapper/analyze.py | 21 ++- src/crapper/cli.py | 3 +- src/crapper/coverage.py | 76 +++++++-- src/crapper/discover.py | 12 ++ src/crapper/languages/typescript.py | 234 ++++++++++++++++++++++++++-- src/crapper/metrics.py | 3 +- src/crapper/model.py | 4 + src/crapper/runners.py | 24 +-- tests/test_cli.py | 4 + tests/test_coverage.py | 23 +++ tests/test_edges.py | 20 +++ tests/test_typescript.py | 81 ++++++++++ 13 files changed, 467 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index afb7b4b..f569041 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/crapper/analyze.py b/src/crapper/analyze.py index eedae2d..9e61ebc 100644 --- a/src/crapper/analyze.py +++ b/src/crapper/analyze.py @@ -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) diff --git a/src/crapper/cli.py b/src/crapper/cli.py index 5a5a21d..11081d3 100644 --- a/src/crapper/cli.py +++ b/src/crapper/cli.py @@ -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. diff --git a/src/crapper/coverage.py b/src/crapper/coverage.py index 5998bab..c4712ba 100644 --- a/src/crapper/coverage.py +++ b/src/crapper/coverage.py @@ -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']*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"]*>", 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) diff --git a/src/crapper/discover.py b/src/crapper/discover.py index f96e674..19db3a0 100644 --- a/src/crapper/discover.py +++ b/src/crapper/discover.py @@ -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 diff --git a/src/crapper/languages/typescript.py b/src/crapper/languages/typescript.py index 67f1699..be9a095 100644 --- a/src/crapper/languages/typescript.py +++ b/src/crapper/languages/typescript.py @@ -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): diff --git a/src/crapper/metrics.py b/src/crapper/metrics.py index 441f2f2..09592da 100644 --- a/src/crapper/metrics.py +++ b/src/crapper/metrics.py @@ -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 = [] diff --git a/src/crapper/model.py b/src/crapper/model.py index dd38055..58b79ee 100644 --- a/src/crapper/model.py +++ b/src/crapper/model.py @@ -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) diff --git a/src/crapper/runners.py b/src/crapper/runners.py index 5a94f17..090f3e9 100644 --- a/src/crapper/runners.py +++ b/src/crapper/runners.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 22b14ba..d1ae243 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_coverage.py b/tests/test_coverage.py index b589044..36dcc75 100644 --- a/tests/test_coverage.py +++ b/tests/test_coverage.py @@ -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" diff --git a/tests/test_edges.py b/tests/test_edges.py index 1c16193..7a85a91 100644 --- a/tests/test_edges.py +++ b/tests/test_edges.py @@ -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") diff --git a/tests/test_typescript.py b/tests/test_typescript.py index 834efda..918a3ea 100644 --- a/tests/test_typescript.py +++ b/tests/test_typescript.py @@ -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), + ]