diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7330e74..c773398 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,3 +51,11 @@ jobs: - name: Verify Weissman test exists run: | grep -q "def test_weissman_score" tests/test_richmack_metrics.py + + - name: Print engineering scorecard + if: success() + env: + GH_TOKEN: ${{ github.token }} + SERVESENSE_ACTIVE_HOURS: "3.3" + run: | + ./tools/servesense_score.sh diff --git a/tests/test_richmack_metrics.py b/tests/test_richmack_metrics.py index b8f9c62..6df1d4e 100644 --- a/tests/test_richmack_metrics.py +++ b/tests/test_richmack_metrics.py @@ -1,57 +1,84 @@ -from tools.richmack_metrics import calculate_metrics +from tools.richmack_metrics import component_scores -def test_weissman_score(): - metrics = calculate_metrics( - merged_prs=4, - closed_issues=4, - open_issues=5, +def sample(): + return component_scores( + merged_prs=6, + closed_issues=5, + open_issues=4, open_prs=0, - issue_closure_rate=44.4, - pr_merge_rate=100.0, - roadmap_completion=44.4, + tests_passed=11, + tests_total=11, + ci_success=True, + active_hours=10, + python_files=5, + lines=1500, + functions=50, + test_count=15, + workflow_count=2, + automation_triggers=3, + automation_capabilities=4, ) + +def test_complexity_score_is_bounded(): + result = sample() + + assert 0 <= result["complexity"] <= 10 + + +def test_maintainability_score_is_bounded(): + result = sample() + + assert 0 <= result["maintainability"] <= 10 + + +def test_throughput_score_is_bounded(): + result = sample() + + assert 0 <= result["throughput"] <= 10 + + +def test_reliability_score_is_bounded(): + result = sample() + + assert result["reliability"] == 10.0 + + +def test_velocity_score_is_bounded(): + result = sample() + + assert 0 <= result["velocity"] <= 10 + + +def test_weissman_score_is_weighted_components(): + result = sample() + expected = round( - ((1 + 4) * (1 + 4)) - / (1 + 5 + 0), + result["complexity"] * 0.15 + + result["maintainability"] * 0.15 + + result["throughput"] * 0.15 + + result["reliability"] * 0.15 + + result["velocity"] * 0.10 + + result["automation"] * 0.15 + + result["testing"] * 0.15, 2, ) - assert metrics["weissman_score"] == expected + assert result["final_score"] == expected -def test_richmack_score(): - metrics = calculate_metrics( - merged_prs=4, - closed_issues=4, - open_issues=5, - open_prs=0, - issue_closure_rate=44.4, - pr_merge_rate=100.0, - roadmap_completion=44.4, - ) +def test_score_is_deterministic(): + assert sample() == sample() - expected = round( - 44.4 * 0.40 - + 100.0 * 0.35 - + 44.4 * 0.25, - 1, - ) - assert metrics["richmack_score"] == expected +def test_automation_score_is_bounded(): + result = sample() + assert 0 <= result["automation"] <= 10 -def test_zero_work_does_not_divide_by_zero(): - metrics = calculate_metrics( - merged_prs=0, - closed_issues=0, - open_issues=0, - open_prs=0, - issue_closure_rate=0, - pr_merge_rate=0, - roadmap_completion=0, - ) - assert metrics["weissman_score"] == 1.0 - assert metrics["richmack_score"] == 0.0 +def test_testing_score_is_bounded(): + result = sample() + + assert 0 <= result["testing"] <= 10 diff --git a/tools/richmack_metrics.py b/tools/richmack_metrics.py index 60dea72..ec02770 100755 --- a/tools/richmack_metrics.py +++ b/tools/richmack_metrics.py @@ -1,67 +1,564 @@ #!/usr/bin/env python3 -""" -Internal ServeSense engineering metrics. +from __future__ import annotations -Not exposed through the restaurant-facing Flask application. -These formulas are intended for CI/developer analysis only. -""" +import ast +import os +from pathlib import Path -def calculate_metrics( +ROOT = Path(__file__).resolve().parents[1] + + +def clamp(value, low=0.0, high=10.0): + return max(low, min(high, value)) + + +def python_stats(root=ROOT): + files = list((root / "app").rglob("*.py")) + + total_lines = 0 + functions = 0 + classes = 0 + + for path in files: + text = path.read_text( + encoding="utf-8", + errors="ignore", + ) + + total_lines += len(text.splitlines()) + + try: + tree = ast.parse(text) + except SyntaxError: + continue + + functions += sum( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + for node in ast.walk(tree) + ) + + classes += sum( + isinstance(node, ast.ClassDef) + for node in ast.walk(tree) + ) + + return { + "python_files": len(files), + "lines": total_lines, + "functions": functions, + "classes": classes, + } + + +def automation_stats(root=ROOT): + workflows = list( + (root / ".github" / "workflows").glob("*.yml") + ) + list( + (root / ".github" / "workflows").glob("*.yaml") + ) + + text = "\n".join( + path.read_text( + encoding="utf-8", + errors="ignore", + ) + for path in workflows + ) + + triggers = sum( + token in text + for token in ( + "pull_request:", + "push:", + "workflow_dispatch:", + ) + ) + + capabilities = sum( + token in text + for token in ( + "pytest", + "docker/build-push-action", + "ghcr.io", + "setup-python", + ) + ) + + return { + "workflow_count": len(workflows), + "automation_triggers": triggers, + "automation_capabilities": capabilities, + } + + +def count_tests(root=ROOT): + tests = 0 + + for path in (root / "tests").glob("test_*.py"): + text = path.read_text( + encoding="utf-8", + errors="ignore", + ) + + try: + tree = ast.parse(text) + except SyntaxError: + continue + + tests += sum( + isinstance(node, ast.FunctionDef) + and node.name.startswith("test_") + for node in ast.walk(tree) + ) + + return tests + + +def component_scores( *, merged_prs, closed_issues, open_issues, open_prs, - issue_closure_rate, - pr_merge_rate, - roadmap_completion, + tests_passed, + tests_total, + ci_success, + active_hours, + python_files, + lines, + functions, + test_count, + workflow_count, + automation_triggers, + automation_capabilities, ): - richmack_score = round( - issue_closure_rate * 0.40 - + pr_merge_rate * 0.35 - + roadmap_completion * 0.25, - 1, + active_hours = max(float(active_hours), 0.1) + + # --------------------------------------------------------- + # COMPLEXITY — 0..10 + # + # Rewards meaningful functional density without rewarding + # unlimited code volume. + # --------------------------------------------------------- + + function_density = ( + functions / max(python_files, 1) + ) + + line_complexity = min( + lines / 1500.0, + 1.0, ) - weissman_score = round( + function_complexity = min( + function_density / 25.0, + 1.0, + ) + + delivered_features = ( + merged_prs + closed_issues + ) + + feature_complexity = min( + delivered_features / 12.0, + 1.0, + ) + + complexity = clamp( ( - (1 + merged_prs) - * (1 + closed_issues) + line_complexity * 0.30 + + function_complexity * 0.30 + + feature_complexity * 0.40 ) - / + * 10 + ) + + # --------------------------------------------------------- + # MAINTAINABILITY — 0..10 + # + # Tests, test-to-function ratio, CI presence/health, + # and manageable functional density. + # --------------------------------------------------------- + + test_ratio = min( + test_count / max(functions * 0.25, 1), + 1.0, + ) + + test_volume = min( + test_count / 15.0, + 1.0, + ) + + ci_factor = 1.0 if ci_success else 0.0 + + density_penalty = min( + function_density / 50.0, + 1.0, + ) + + structure_factor = ( + 1.0 - density_penalty * 0.30 + ) + + maintainability = clamp( ( - 1 - + open_issues - + open_prs - ), + test_ratio * 0.35 + + test_volume * 0.20 + + ci_factor * 0.30 + + structure_factor * 0.15 + ) + * 10 + ) + + # --------------------------------------------------------- + # THROUGHPUT — 0..10 + # + # Delivered units per active development hour. + # + # 1 shipped unit / hour ~= 10/10. + # --------------------------------------------------------- + + shipped_units = ( + merged_prs + + closed_issues + ) + + units_per_hour = ( + shipped_units / active_hours + ) + + throughput = clamp( + units_per_hour * 10 + ) + + # --------------------------------------------------------- + # RELIABILITY — 0..10 + # + # Current pipeline health + test execution health. + # --------------------------------------------------------- + + pass_rate = ( + tests_passed / tests_total + if tests_total + else 0 + ) + + reliability = clamp( + ( + (1.0 if ci_success else 0.0) * 0.60 + + pass_rate * 0.40 + ) + * 10 + ) + + # --------------------------------------------------------- + # VELOCITY — 0..10 + # --------------------------------------------------------- + + completed_per_hour = ( + closed_issues / active_hours + ) + + completion_ratio = ( + closed_issues + / max( + closed_issues + open_issues, + 1, + ) + ) + + velocity = clamp( + ( + min(completed_per_hour, 1.0) * 0.60 + + completion_ratio * 0.40 + ) + * 10 + ) + + # --------------------------------------------------------- + # AUTOMATION — 0..10 + # + # Measures automated workflows, triggers, and capabilities. + # --------------------------------------------------------- + + workflow_factor = min( + workflow_count / 3.0, + 1.0, + ) + + trigger_factor = min( + automation_triggers / 3.0, + 1.0, + ) + + capability_factor = min( + automation_capabilities / 4.0, + 1.0, + ) + + automation = clamp( + ( + workflow_factor * 0.35 + + trigger_factor * 0.30 + + capability_factor * 0.35 + ) + * 10 + ) + + # --------------------------------------------------------- + # TESTING — 0..10 + # + # Pass rate + test volume + test/function density. + # --------------------------------------------------------- + + test_volume_factor = min( + test_count / 20.0, + 1.0, + ) + + test_density_factor = min( + test_count / max(functions * 0.30, 1), + 1.0, + ) + + testing = clamp( + ( + pass_rate * 0.50 + + test_volume_factor * 0.25 + + test_density_factor * 0.25 + ) + * 10 + ) + + # --------------------------------------------------------- + # FINAL MULTI-FACTOR ENGINEERING SCORE + # + # Complexity 15% + # Maintainability 15% + # Throughput 15% + # Reliability 15% + # Velocity 10% + # Automation 15% + # Testing 15% + # --------------------------------------------------------- + + final_score = round( + complexity * 0.15 + + maintainability * 0.15 + + throughput * 0.15 + + reliability * 0.15 + + velocity * 0.10 + + automation * 0.15 + + testing * 0.15, 2, ) return { - "richmack_score": richmack_score, - "weissman_score": weissman_score, + "complexity": round(complexity, 2), + "maintainability": round( + maintainability, + 2, + ), + "throughput": round( + throughput, + 2, + ), + "reliability": round( + reliability, + 2, + ), + "velocity": round( + velocity, + 2, + ), + "automation": round( + automation, + 2, + ), + "testing": round( + testing, + 2, + ), + "final_score": final_score, + "units_per_hour": round( + units_per_hour, + 3, + ), + } + + +def calculate_metrics( + *, + merged_prs, + closed_issues, + open_issues, + open_prs, + tests_passed, + tests_total, + ci_success, + active_hours, +): + stats = python_stats() + test_count = count_tests() + auto = automation_stats() + + scores = component_scores( + merged_prs=merged_prs, + closed_issues=closed_issues, + open_issues=open_issues, + open_prs=open_prs, + tests_passed=tests_passed, + tests_total=tests_total, + ci_success=ci_success, + active_hours=active_hours, + python_files=stats["python_files"], + lines=stats["lines"], + functions=stats["functions"], + test_count=test_count, + workflow_count=auto["workflow_count"], + automation_triggers=auto["automation_triggers"], + automation_capabilities=auto["automation_capabilities"], + ) + + return { + **stats, + **auto, + "test_count": test_count, + **scores, } -if __name__ == "__main__": - example = calculate_metrics( - merged_prs=4, - closed_issues=4, - open_issues=5, - open_prs=0, - issue_closure_rate=44.4, - pr_merge_rate=100.0, - roadmap_completion=44.4, +def print_scorecard(metrics): + print("=== SERVESENSE ENGINEERING SCORECARD ===") + print() + + print( + f"Complexity: " + f"{metrics['complexity']:.2f} / 10" + ) + + print( + f"Maintainability: " + f"{metrics['maintainability']:.2f} / 10" + ) + + print( + f"Throughput: " + f"{metrics['throughput']:.2f} / 10" + ) + + print( + f"Reliability: " + f"{metrics['reliability']:.2f} / 10" + ) + + print( + f"Velocity: " + f"{metrics['velocity']:.2f} / 10" + ) + + print( + f"Automation: " + f"{metrics['automation']:.2f} / 10" + ) + + print( + f"Testing: " + f"{metrics['testing']:.2f} / 10" + ) + + print() + print( + f"WEISSMAN-STYLE SCORE: " + f"{metrics['final_score']:.2f} / 10" + ) + + print() + print( + f"Delivery rate: " + f"{metrics['units_per_hour']:.3f} " + f"units/hour" ) print( - f"Richmack Score: " - f"{example['richmack_score']}/100" + f"Python LOC: " + f"{metrics['lines']}" ) print( - f"Weissman-style Index: " - f"{example['weissman_score']}x" + f"Functions: " + f"{metrics['functions']}" ) + + print( + f"Automated tests: " + f"{metrics['test_count']}" + ) + + print( + f"Automation workflows: " + f"{metrics['workflow_count']}" + ) + + print( + f"Automation triggers: " + f"{metrics['automation_triggers']}" + ) + + +if __name__ == "__main__": + active_hours = float( + os.getenv( + "SERVESENSE_ACTIVE_HOURS", + "1", + ) + ) + + metrics = calculate_metrics( + merged_prs=int( + os.getenv( + "SERVESENSE_MERGED_PRS", + "0", + ) + ), + closed_issues=int( + os.getenv( + "SERVESENSE_CLOSED_ISSUES", + "0", + ) + ), + open_issues=int( + os.getenv( + "SERVESENSE_OPEN_ISSUES", + "0", + ) + ), + open_prs=int( + os.getenv( + "SERVESENSE_OPEN_PRS", + "0", + ) + ), + tests_passed=int( + os.getenv( + "SERVESENSE_TESTS_PASSED", + "0", + ) + ), + tests_total=int( + os.getenv( + "SERVESENSE_TESTS_TOTAL", + "0", + ) + ), + ci_success=os.getenv( + "SERVESENSE_CI_SUCCESS", + "0", + ) == "1", + active_hours=active_hours, + ) + + print_scorecard(metrics) diff --git a/tools/servesense_score.sh b/tools/servesense_score.sh new file mode 100755 index 0000000..56f721e --- /dev/null +++ b/tools/servesense_score.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +set -e + +ROOT="$( + cd "$(dirname "$0")/.." && + pwd +)" + +cd "$ROOT" + +OPEN_ISSUES="$( + gh issue list \ + --state open \ + --limit 1000 \ + --json number \ + --jq 'length' +)" + +CLOSED_ISSUES="$( + gh issue list \ + --state closed \ + --limit 1000 \ + --json number \ + --jq 'length' +)" + +OPEN_PRS="$( + gh pr list \ + --state open \ + --limit 1000 \ + --json number \ + --jq 'length' +)" + +MERGED_PRS="$( + gh pr list \ + --state merged \ + --limit 1000 \ + --json number \ + --jq 'length' +)" + +ACTIVE_HOURS="${SERVESENSE_ACTIVE_HOURS:-1}" + +TEST_OUTPUT="$( + PYTHONPATH="$ROOT" \ + python3 -m pytest -q +)" + +TEST_TOTAL="$( + printf '%s\n' "$TEST_OUTPUT" \ + | grep -Eo '[0-9]+ passed' \ + | tail -1 \ + | awk '{print $1}' +)" + +TEST_TOTAL="${TEST_TOTAL:-0}" + +export SERVESENSE_OPEN_ISSUES="$OPEN_ISSUES" +export SERVESENSE_CLOSED_ISSUES="$CLOSED_ISSUES" +export SERVESENSE_OPEN_PRS="$OPEN_PRS" +export SERVESENSE_MERGED_PRS="$MERGED_PRS" +export SERVESENSE_TESTS_PASSED="$TEST_TOTAL" +export SERVESENSE_TESTS_TOTAL="$TEST_TOTAL" +export SERVESENSE_CI_SUCCESS=1 +export SERVESENSE_ACTIVE_HOURS="$ACTIVE_HOURS" + +python3 tools/richmack_metrics.py