From a266231a2388fdfd16648b3450b20f08b14deab7 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 23 May 2026 22:05:49 +0100 Subject: [PATCH 1/7] Add mutation and CRAP quality indicators --- docs/MIGRATION_AND_PARITY.md | 1 + docs/QUALITY_GATES.md | 53 ++++++ docs/SECURITY_POSTURE_AND_TESTING.md | 6 + scripts/ci/run-fast-checks.sh | 2 + scripts/ci/run-quality-indicators.sh | 43 +++++ scripts/quality/crap_indicator.py | 238 +++++++++++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 docs/QUALITY_GATES.md create mode 100755 scripts/ci/run-quality-indicators.sh create mode 100755 scripts/quality/crap_indicator.py diff --git a/docs/MIGRATION_AND_PARITY.md b/docs/MIGRATION_AND_PARITY.md index 90d50b6..a5d26a0 100644 --- a/docs/MIGRATION_AND_PARITY.md +++ b/docs/MIGRATION_AND_PARITY.md @@ -56,6 +56,7 @@ Primary Rust gates: cargo fmt --manifest-path rust/Cargo.toml -- --check cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings cargo test --manifest-path rust/Cargo.toml --all-targets +bash scripts/ci/run-quality-indicators.sh ``` Legacy parity harness: diff --git a/docs/QUALITY_GATES.md b/docs/QUALITY_GATES.md new file mode 100644 index 0000000..e8d42e3 --- /dev/null +++ b/docs/QUALITY_GATES.md @@ -0,0 +1,53 @@ +# Quality gates + +APW keeps PR checks cheap, but release and readiness work should include a +deeper quality signal when code changes affect credential handling, process +execution, or operator diagnostics. + +## Fast checks + +Run the normal local gate before opening a PR: + +```bash +bash scripts/ci/run-fast-checks.sh +cargo fmt --manifest-path rust/Cargo.toml -- --check +cargo clippy --manifest-path rust/Cargo.toml --all-targets -- -D warnings +cargo test --manifest-path rust/Cargo.toml +``` + +## Mutation testing + +Mutation testing is opt-in because it is intentionally slower than PR Fast CI. +Install `cargo-mutants`, then run: + +```bash +APW_RUN_MUTATION=1 bash scripts/ci/run-quality-indicators.sh +``` + +The script writes mutation output under `rust/target/mutants` and keeps it out +of source control. + +## CRAP indicator + +The CRAP indicator highlights functions whose estimated complexity is high +relative to test coverage: + +```text +complexity^2 * (1 - coverage)^3 + complexity +``` + +Run the source-only hotspot report: + +```bash +bash scripts/ci/run-quality-indicators.sh +``` + +For coverage-aware scores, install `cargo-llvm-cov` and run: + +```bash +APW_RUN_COVERAGE=1 bash scripts/ci/run-quality-indicators.sh +``` + +Without LCOV coverage input, the script assumes 0% coverage and produces a +conservative hotspot list. That mode is useful for deciding where to add tests +before enabling coverage tooling on a runner. diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index 9b5a30f..633e7f6 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -62,8 +62,14 @@ cargo test --manifest-path rust/Cargo.toml --test legacy_parity cargo test --manifest-path rust/Cargo.toml --test native_app_e2e cargo build --manifest-path rust/Cargo.toml --release ./scripts/build-native-app.sh +bash scripts/ci/run-quality-indicators.sh ``` +For high-risk changes, enable the deeper optional checks described in +[QUALITY_GATES.md](QUALITY_GATES.md): `APW_RUN_COVERAGE=1` adds LCOV-backed CRAP +scores when `cargo-llvm-cov` is installed, and `APW_RUN_MUTATION=1` runs +`cargo-mutants` when available. + ## Security-focused regression coverage The Rust test suite covers: diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 587f6d2..7f2c07b 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -35,6 +35,8 @@ while IFS= read -r -d '' script; do bash -n "$script" done < <(find .github/scripts scripts -type f -name '*.sh' -print0) +python3 scripts/quality/crap_indicator.py --self-test + ./scripts/test-render-homebrew-formula.sh echo "APW fast checks passed." diff --git a/scripts/ci/run-quality-indicators.sh b/scripts/ci/run-quality-indicators.sh new file mode 100755 index 0000000..d0fc87c --- /dev/null +++ b/scripts/ci/run-quality-indicators.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +echo "Running APW quality indicators..." + +LCOV_PATH="${APW_LCOV_PATH:-}" + +if [[ "${APW_RUN_COVERAGE:-0}" == "1" ]]; then + if command -v cargo-llvm-cov >/dev/null 2>&1; then + LCOV_PATH="${LCOV_PATH:-rust/target/apw-lcov.info}" + cargo llvm-cov \ + --manifest-path rust/Cargo.toml \ + --lcov \ + --output-path "$LCOV_PATH" + else + echo "Skipping coverage collection: cargo-llvm-cov is not installed." >&2 + fi +fi + +if [[ "${APW_RUN_MUTATION:-0}" == "1" ]]; then + if command -v cargo-mutants >/dev/null 2>&1; then + cargo mutants \ + --manifest-path rust/Cargo.toml \ + --output rust/target/mutants \ + --timeout "${APW_MUTATION_TIMEOUT:-30}" + else + echo "Skipping mutation testing: cargo-mutants is not installed." >&2 + fi +fi + +CRAP_ARGS=(rust/src --limit "${APW_CRAP_LIMIT:-20}" --threshold "${APW_CRAP_THRESHOLD:-30}") +if [[ -n "$LCOV_PATH" && -f "$LCOV_PATH" ]]; then + CRAP_ARGS+=(--lcov "$LCOV_PATH") +else + echo "No LCOV file supplied; CRAP report uses conservative 0% coverage." >&2 +fi + +python3 scripts/quality/crap_indicator.py "${CRAP_ARGS[@]}" + +echo "APW quality indicators passed." diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py new file mode 100755 index 0000000..647b9c8 --- /dev/null +++ b/scripts/quality/crap_indicator.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Compute a lightweight CRAP-style risk indicator for Rust functions. + +The real CRAP score is: + + complexity^2 * (1 - coverage)^3 + complexity + +This script intentionally keeps dependencies at zero. It estimates cyclomatic +complexity from Rust source and optionally reads line coverage from an LCOV +file produced by tools such as `cargo llvm-cov --lcov`. Without LCOV input it +uses 0% coverage, which makes the report a conservative hotspot indicator. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +FN_RE = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(") +COMPLEXITY_RE = re.compile(r"\b(if|match|for|while)\b|&&|\|\||=>") + + +@dataclass +class FunctionMetric: + path: Path + name: str + start: int + end: int + complexity: int + coverage: float + + @property + def crap(self) -> float: + return (self.complexity**2) * ((1.0 - self.coverage) ** 3) + self.complexity + + +def rust_files(paths: Iterable[Path]) -> list[Path]: + files: list[Path] = [] + for path in paths: + if path.is_file() and path.suffix == ".rs": + files.append(path) + elif path.is_dir(): + files.extend(sorted(p for p in path.rglob("*.rs") if "target" not in p.parts)) + return sorted(files) + + +def line_coverage(lcov: Path | None) -> dict[Path, dict[int, int]]: + if lcov is None: + return {} + coverage: dict[Path, dict[int, int]] = {} + current: Path | None = None + for raw in lcov.read_text(encoding="utf-8").splitlines(): + if raw.startswith("SF:"): + current = Path(raw[3:]).resolve() + coverage.setdefault(current, {}) + elif raw.startswith("DA:") and current is not None: + line_s, hits_s = raw[3:].split(",", 1) + coverage[current][int(line_s)] = int(hits_s) + elif raw == "end_of_record": + current = None + return coverage + + +def function_coverage(path: Path, start: int, end: int, coverage: dict[Path, dict[int, int]]) -> float: + line_hits = coverage.get(path.resolve()) + if not line_hits: + return 0.0 + executable = [line for line in range(start, end + 1) if line in line_hits] + if not executable: + return 0.0 + covered = sum(1 for line in executable if line_hits[line] > 0) + return covered / len(executable) + + +def count_complexity(lines: list[str]) -> int: + complexity = 1 + for line in lines: + stripped = line.split("//", 1)[0] + complexity += len(COMPLEXITY_RE.findall(stripped)) + return complexity + + +def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[FunctionMetric]: + lines = path.read_text(encoding="utf-8").splitlines() + metrics: list[FunctionMetric] = [] + index = 0 + while index < len(lines): + match = FN_RE.match(lines[index]) + if not match: + index += 1 + continue + name = match.group(1) + start_index = index + body_lines: list[str] = [] + depth = 0 + seen_body = False + while index < len(lines): + line = lines[index] + body_lines.append(line) + depth += line.count("{") + if "{" in line: + seen_body = True + depth -= line.count("}") + if seen_body and depth <= 0: + break + index += 1 + end_index = index + metrics.append( + FunctionMetric( + path=path, + name=name, + start=start_index + 1, + end=end_index + 1, + complexity=count_complexity(body_lines), + coverage=function_coverage(path, start_index + 1, end_index + 1, coverage), + ) + ) + index += 1 + return metrics + + +def render_markdown(metrics: list[FunctionMetric], limit: int) -> str: + rows = ["| CRAP | Complexity | Coverage | Function |", "| ---: | ---: | ---: | --- |"] + for metric in metrics[:limit]: + rows.append( + "| " + f"{metric.crap:.1f} | {metric.complexity} | {metric.coverage * 100:.1f}% | " + f"`{metric.path}:{metric.start}` `{metric.name}` |" + ) + return "\n".join(rows) + + +def run(paths: list[Path], lcov: Path | None, limit: int) -> list[FunctionMetric]: + coverage = line_coverage(lcov) + metrics: list[FunctionMetric] = [] + for path in rust_files(paths): + metrics.extend(parse_functions(path, coverage)) + metrics.sort(key=lambda metric: metric.crap, reverse=True) + return metrics[:limit] + + +def self_test() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "sample.rs" + source.write_text( + "\n".join( + [ + "fn simple() {", + " println!(\"ok\");", + "}", + "fn branchy(value: bool) {", + " if value && true {", + " match value { true => (), false => () }", + " }", + "}", + ] + ), + encoding="utf-8", + ) + lcov = root / "lcov.info" + lcov.write_text( + "\n".join( + [ + f"SF:{source}", + "DA:1,1", + "DA:2,1", + "DA:3,1", + "DA:4,1", + "DA:5,0", + "DA:6,0", + "DA:7,0", + "DA:8,1", + "end_of_record", + ] + ), + encoding="utf-8", + ) + metrics = run([source], lcov, 10) + assert metrics[0].name == "branchy" + assert metrics[0].complexity > metrics[1].complexity + assert metrics[1].coverage == 1.0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="*", type=Path, default=[Path("rust/src")]) + parser.add_argument("--lcov", type=Path, help="Optional LCOV file for line coverage.") + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--threshold", type=float, default=30.0) + parser.add_argument("--fail-threshold", action="store_true") + parser.add_argument("--json", action="store_true", dest="json_output") + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + + if args.self_test: + self_test() + return 0 + + metrics = run(args.paths, args.lcov, args.limit) + if args.json_output: + print( + json.dumps( + [ + { + "path": str(metric.path), + "line": metric.start, + "name": metric.name, + "complexity": metric.complexity, + "coverage": round(metric.coverage, 4), + "crap": round(metric.crap, 2), + } + for metric in metrics + ], + indent=2, + ) + ) + else: + print(render_markdown(metrics, args.limit)) + + if args.fail_threshold and metrics and metrics[0].crap > args.threshold: + print( + f"CRAP threshold exceeded: {metrics[0].crap:.1f} > {args.threshold:.1f}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 79f22b4b048a9d3f2953cfcb8e99e0ca4079c4c3 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 02:09:20 +0100 Subject: [PATCH 2/7] Fail closed for requested quality tools --- docs/QUALITY_GATES.md | 17 +++++++++++++++-- scripts/ci/run-fast-checks.sh | 3 +-- scripts/ci/run-quality-indicators.sh | 8 ++++++-- scripts/test-quality-indicators.sh | 25 +++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) create mode 100755 scripts/test-quality-indicators.sh diff --git a/docs/QUALITY_GATES.md b/docs/QUALITY_GATES.md index e8d42e3..974bfe8 100644 --- a/docs/QUALITY_GATES.md +++ b/docs/QUALITY_GATES.md @@ -25,7 +25,8 @@ APW_RUN_MUTATION=1 bash scripts/ci/run-quality-indicators.sh ``` The script writes mutation output under `rust/target/mutants` and keeps it out -of source control. +of source control. When `APW_RUN_MUTATION=1` is set, missing `cargo-mutants` +is a hard failure rather than a silent skip. ## CRAP indicator @@ -50,4 +51,16 @@ APW_RUN_COVERAGE=1 bash scripts/ci/run-quality-indicators.sh Without LCOV coverage input, the script assumes 0% coverage and produces a conservative hotspot list. That mode is useful for deciding where to add tests -before enabling coverage tooling on a runner. +before enabling coverage tooling on a runner. When `APW_RUN_COVERAGE=1` is +set, missing `cargo-llvm-cov` is a hard failure rather than a silent skip. + +## Tooling regression tests + +Fast checks run: + +```bash +./scripts/test-quality-indicators.sh +``` + +This covers the CRAP self-test and verifies that explicitly requested mutation +or coverage runs fail closed when their required tools are unavailable. diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 7f2c07b..e65601c 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -35,8 +35,7 @@ while IFS= read -r -d '' script; do bash -n "$script" done < <(find .github/scripts scripts -type f -name '*.sh' -print0) -python3 scripts/quality/crap_indicator.py --self-test - ./scripts/test-render-homebrew-formula.sh +./scripts/test-quality-indicators.sh echo "APW fast checks passed." diff --git a/scripts/ci/run-quality-indicators.sh b/scripts/ci/run-quality-indicators.sh index d0fc87c..dad0c01 100755 --- a/scripts/ci/run-quality-indicators.sh +++ b/scripts/ci/run-quality-indicators.sh @@ -16,7 +16,9 @@ if [[ "${APW_RUN_COVERAGE:-0}" == "1" ]]; then --lcov \ --output-path "$LCOV_PATH" else - echo "Skipping coverage collection: cargo-llvm-cov is not installed." >&2 + echo "cargo-llvm-cov is required when APW_RUN_COVERAGE=1." >&2 + echo "Install it or unset APW_RUN_COVERAGE to run the source-only CRAP report." >&2 + exit 1 fi fi @@ -27,7 +29,9 @@ if [[ "${APW_RUN_MUTATION:-0}" == "1" ]]; then --output rust/target/mutants \ --timeout "${APW_MUTATION_TIMEOUT:-30}" else - echo "Skipping mutation testing: cargo-mutants is not installed." >&2 + echo "cargo-mutants is required when APW_RUN_MUTATION=1." >&2 + echo "Install it or unset APW_RUN_MUTATION to run CRAP indicators only." >&2 + exit 1 fi fi diff --git a/scripts/test-quality-indicators.sh b/scripts/test-quality-indicators.sh new file mode 100755 index 0000000..83a111e --- /dev/null +++ b/scripts/test-quality-indicators.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +bash -n scripts/ci/run-quality-indicators.sh +python3 scripts/quality/crap_indicator.py --self-test + +EMPTY_PATH="$(mktemp -d "${TMPDIR:-/tmp}/apw-quality-empty-path.XXXXXX")" +trap 'rm -rf "$EMPTY_PATH"' EXIT + +if PATH="$EMPTY_PATH" APW_RUN_MUTATION=1 /bin/bash scripts/ci/run-quality-indicators.sh 2>"$EMPTY_PATH/mutation.err"; then + echo "Expected APW_RUN_MUTATION=1 to fail when cargo-mutants is missing." >&2 + exit 1 +fi +grep -q "cargo-mutants is required when APW_RUN_MUTATION=1" "$EMPTY_PATH/mutation.err" + +if PATH="$EMPTY_PATH" APW_RUN_COVERAGE=1 /bin/bash scripts/ci/run-quality-indicators.sh 2>"$EMPTY_PATH/coverage.err"; then + echo "Expected APW_RUN_COVERAGE=1 to fail when cargo-llvm-cov is missing." >&2 + exit 1 +fi +grep -q "cargo-llvm-cov is required when APW_RUN_COVERAGE=1" "$EMPTY_PATH/coverage.err" + +echo "Quality indicator tests passed." From c798ca8f5275a4646aa4494e9b93e7398f818c1c Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 05:36:47 +0100 Subject: [PATCH 3/7] Detect qualified Rust functions in CRAP scan --- docs/QUALITY_GATES.md | 3 +++ scripts/quality/crap_indicator.py | 36 +++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/QUALITY_GATES.md b/docs/QUALITY_GATES.md index 974bfe8..09eb21f 100644 --- a/docs/QUALITY_GATES.md +++ b/docs/QUALITY_GATES.md @@ -53,6 +53,9 @@ Without LCOV coverage input, the script assumes 0% coverage and produces a conservative hotspot list. That mode is useful for deciding where to add tests before enabling coverage tooling on a runner. When `APW_RUN_COVERAGE=1` is set, missing `cargo-llvm-cov` is a hard failure rather than a silent skip. +The source scanner recognizes ordinary Rust functions plus qualified forms such +as `async fn`, `const fn`, `unsafe fn`, and `extern "C" fn`, including +visibility-qualified declarations. ## Tooling regression tests diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py index 647b9c8..c2aeb5d 100755 --- a/scripts/quality/crap_indicator.py +++ b/scripts/quality/crap_indicator.py @@ -23,7 +23,9 @@ from typing import Iterable -FN_RE = re.compile(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(") +FN_RE = re.compile( + r'^\s*(?:pub(?:\([^)]*\))?\s+)?(?:(?:async|const|unsafe)\s+)*(?:extern\s+"[^"]+"\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(' +) COMPLEXITY_RE = re.compile(r"\b(if|match|for|while)\b|&&|\|\||=>") @@ -156,6 +158,15 @@ def self_test() -> None: "fn simple() {", " println!(\"ok\");", "}", + "pub(crate) const fn const_helper() -> bool {", + " true", + "}", + "unsafe fn unsafe_helper(value: bool) {", + " if value { println!(\"unsafe\"); }", + "}", + "pub extern \"C\" fn ffi_helper(value: bool) {", + " if value { println!(\"ffi\"); }", + "}", "fn branchy(value: bool) {", " if value && true {", " match value { true => (), false => () }", @@ -174,19 +185,32 @@ def self_test() -> None: "DA:2,1", "DA:3,1", "DA:4,1", - "DA:5,0", - "DA:6,0", + "DA:5,1", + "DA:6,1", "DA:7,0", - "DA:8,1", + "DA:8,0", + "DA:9,0", + "DA:10,0", + "DA:11,0", + "DA:12,0", + "DA:13,0", + "DA:14,0", + "DA:15,1", + "DA:16,0", + "DA:17,0", + "DA:18,1", "end_of_record", ] ), encoding="utf-8", ) metrics = run([source], lcov, 10) + names = {metric.name for metric in metrics} + assert {"const_helper", "unsafe_helper", "ffi_helper"}.issubset(names) assert metrics[0].name == "branchy" - assert metrics[0].complexity > metrics[1].complexity - assert metrics[1].coverage == 1.0 + simple = next(metric for metric in metrics if metric.name == "simple") + assert metrics[0].complexity > simple.complexity + assert simple.coverage == 1.0 def main() -> int: From 1782b1e69f65417328b2a90de49b324a8dd91909 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:28:31 +0100 Subject: [PATCH 4/7] Ignore Rust function declarations in CRAP scan --- scripts/quality/crap_indicator.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py index c2aeb5d..015f2ce 100755 --- a/scripts/quality/crap_indicator.py +++ b/scripts/quality/crap_indicator.py @@ -103,9 +103,13 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu body_lines: list[str] = [] depth = 0 seen_body = False + is_declaration = False while index < len(lines): line = lines[index] body_lines.append(line) + if not seen_body and "{" not in line and ";" in line: + is_declaration = True + break depth += line.count("{") if "{" in line: seen_body = True @@ -114,6 +118,9 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu break index += 1 end_index = index + if is_declaration or not seen_body: + index += 1 + continue metrics.append( FunctionMetric( path=path, @@ -167,6 +174,15 @@ def self_test() -> None: "pub extern \"C\" fn ffi_helper(value: bool) {", " if value { println!(\"ffi\"); }", "}", + "trait Strategy {", + " fn strategy(&self) -> &'static str;", + " fn multiline(", + " &self,", + " ) -> bool;", + "}", + "fn after_trait() {", + " println!(\"after\");", + "}", "fn branchy(value: bool) {", " if value && true {", " match value { true => (), false => () }", @@ -207,6 +223,9 @@ def self_test() -> None: metrics = run([source], lcov, 10) names = {metric.name for metric in metrics} assert {"const_helper", "unsafe_helper", "ffi_helper"}.issubset(names) + assert "strategy" not in names + assert "multiline" not in names + assert "after_trait" in names assert metrics[0].name == "branchy" simple = next(metric for metric in metrics if metric.name == "simple") assert metrics[0].complexity > simple.complexity From 6d74aafca49a9e434b620bdc601e4141b81bce37 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:05:51 +0100 Subject: [PATCH 5/7] Clarify CRAP bodyless fn parsing Extract the bodyless function declaration predicate and extend the self-test with a multiline generic trait method so CRAP metrics do not attribute declarations as concrete Rust function bodies. --- scripts/quality/crap_indicator.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py index 015f2ce..2338963 100755 --- a/scripts/quality/crap_indicator.py +++ b/scripts/quality/crap_indicator.py @@ -89,6 +89,10 @@ def count_complexity(lines: list[str]) -> int: return complexity +def ends_bodyless_function_declaration(line: str, seen_body: bool) -> bool: + return not seen_body and "{" not in line and ";" in line + + def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[FunctionMetric]: lines = path.read_text(encoding="utf-8").splitlines() metrics: list[FunctionMetric] = [] @@ -107,7 +111,7 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu while index < len(lines): line = lines[index] body_lines.append(line) - if not seen_body and "{" not in line and ";" in line: + if ends_bodyless_function_declaration(line, seen_body): is_declaration = True break depth += line.count("{") @@ -179,6 +183,10 @@ def self_test() -> None: " fn multiline(", " &self,", " ) -> bool;", + " fn generic(", + " &self,", + " value: T,", + " ) -> Result<(), String>;", "}", "fn after_trait() {", " println!(\"after\");", @@ -225,6 +233,7 @@ def self_test() -> None: assert {"const_helper", "unsafe_helper", "ffi_helper"}.issubset(names) assert "strategy" not in names assert "multiline" not in names + assert "generic" not in names assert "after_trait" in names assert metrics[0].name == "branchy" simple = next(metric for metric in metrics if metric.name == "simple") From ab390a9598856cea4136c81ba3c5c8d8d4de1205 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:10:57 +0100 Subject: [PATCH 6/7] Name CRAP parser body completion Use an explicit helper for closed function bodies so declaration skipping remains separate from concrete Rust body parsing. --- scripts/quality/crap_indicator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py index 2338963..89dd012 100755 --- a/scripts/quality/crap_indicator.py +++ b/scripts/quality/crap_indicator.py @@ -93,6 +93,10 @@ def ends_bodyless_function_declaration(line: str, seen_body: bool) -> bool: return not seen_body and "{" not in line and ";" in line +def has_closed_function_body(seen_body: bool, depth: int) -> bool: + return seen_body and depth <= 0 + + def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[FunctionMetric]: lines = path.read_text(encoding="utf-8").splitlines() metrics: list[FunctionMetric] = [] @@ -118,7 +122,7 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu if "{" in line: seen_body = True depth -= line.count("}") - if seen_body and depth <= 0: + if has_closed_function_body(seen_body, depth): break index += 1 end_index = index From e2a2e749ccfd7ae421da84b42b016d96ead82395 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:12:43 +0100 Subject: [PATCH 7/7] Extract CRAP function body reader Move function body scanning into a dedicated helper that returns whether a concrete body was found, keeping bodyless declarations from participating in metric generation. --- scripts/quality/crap_indicator.py | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/scripts/quality/crap_indicator.py b/scripts/quality/crap_indicator.py index 89dd012..f729ad8 100755 --- a/scripts/quality/crap_indicator.py +++ b/scripts/quality/crap_indicator.py @@ -97,6 +97,27 @@ def has_closed_function_body(seen_body: bool, depth: int) -> bool: return seen_body and depth <= 0 +def read_function_body(lines: list[str], start_index: int) -> tuple[list[str], int, bool]: + body_lines: list[str] = [] + depth = 0 + seen_body = False + index = start_index + while index < len(lines): + line = lines[index] + body_lines.append(line) + if ends_bodyless_function_declaration(line, seen_body): + return body_lines, index, False + + depth += line.count("{") + if "{" in line: + seen_body = True + depth -= line.count("}") + if has_closed_function_body(seen_body, depth): + return body_lines, index, True + index += 1 + return body_lines, index - 1, False + + def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[FunctionMetric]: lines = path.read_text(encoding="utf-8").splitlines() metrics: list[FunctionMetric] = [] @@ -108,26 +129,9 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu continue name = match.group(1) start_index = index - body_lines: list[str] = [] - depth = 0 - seen_body = False - is_declaration = False - while index < len(lines): - line = lines[index] - body_lines.append(line) - if ends_bodyless_function_declaration(line, seen_body): - is_declaration = True - break - depth += line.count("{") - if "{" in line: - seen_body = True - depth -= line.count("}") - if has_closed_function_body(seen_body, depth): - break - index += 1 - end_index = index - if is_declaration or not seen_body: - index += 1 + body_lines, end_index, has_body = read_function_body(lines, start_index) + if not has_body: + index = end_index + 1 continue metrics.append( FunctionMetric( @@ -139,7 +143,7 @@ def parse_functions(path: Path, coverage: dict[Path, dict[int, int]]) -> list[Fu coverage=function_coverage(path, start_index + 1, end_index + 1, coverage), ) ) - index += 1 + index = end_index + 1 return metrics