diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 876f020..086bedc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,11 @@ jobs: with: python-version: "3.12" - name: Install package - run: python -m pip install --upgrade pip && python -m pip install -e . + run: python -m pip install --upgrade pip && python -m pip install -e '.[dev]' + - name: Ruff unused imports + run: python -m ruff check --select F401,F811 src scripts tests + - name: Complexity proving test + run: bash scripts/test-check-complexity.sh - name: Unit tests run: python -m unittest discover -s tests -v env: diff --git a/pyproject.toml b/pyproject.toml index c0d48d9..d380502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,17 @@ requires-python = ">=3.10" dependencies = ["mcp>=1.9.0,<2"] [project.optional-dependencies] -dev = [] +dev = ["ruff>=0.11", "lizard==1.24.0"] [project.scripts] local-coding-slm = "local_coding_slm.server:main" [tool.setuptools.packages.find] where = ["src"] + +[tool.ruff] +target-version = "py310" +src = ["src"] + +[tool.ruff.lint] +select = ["F401", "F811"] diff --git a/scripts/check-complexity.py b/scripts/check-complexity.py new file mode 100755 index 0000000..e324016 --- /dev/null +++ b/scripts/check-complexity.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""PR-diff complexity gate. + +Fails when a *changed* file introduces a new function with CCN > 10 or NLOC > 80, +or when an existing function's CCN rises. Untouched hotspots do not fail. +Docs-only diffs exit 0. +""" + +from __future__ import annotations + +import argparse +import csv +import io +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_CCN = 10 +DEFAULT_NLOC = 80 +EXTS = {".py": "python", ".java": "java"} + + +def git(*args: str, cwd: Path) -> str: + return subprocess.check_output(["git", *args], cwd=cwd, text=True).rstrip("\n") + + +def changed_files(repo: Path, base: str, paths: list[str], exts: set[str]) -> list[str]: + rels = git("diff", "--name-only", "--diff-filter=ACMR", f"{base}...HEAD", "--", *paths, cwd=repo) + files = [] + for line in rels.splitlines(): + line = line.strip() + if not line: + continue + if Path(line).suffix in exts: + files.append(line) + return files + + +def lizard_rows(source: str, filename: str, language: str) -> list[dict[str, str]]: + if not source.strip(): + return [] + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / Path(filename).name + dest.write_text(source, encoding="utf-8") + csv_path = Path(tmp) / "out.csv" + subprocess.run( + ["lizard", "-l", language, "-C", "999", "-L", "999999", "-o", str(csv_path), str(dest)], + check=True, + capture_output=True, + text=True, + ) + text = csv_path.read_text(encoding="utf-8") + rows = [] + reader = csv.reader(io.StringIO(text)) + for rec in reader: + if len(rec) < 9: + continue + rows.append( + { + "nloc": rec[0], + "ccn": rec[1], + "name": rec[7], + "file": filename, + } + ) + return rows + + +def file_at(repo: Path, rev: str, rel: str) -> str | None: + try: + return git("show", f"{rev}:{rel}", cwd=repo) + except subprocess.CalledProcessError: + return None + + +def index_funcs(rows: list[dict[str, str]]) -> dict[str, tuple[int, int]]: + out: dict[str, tuple[int, int]] = {} + for row in rows: + out[row["name"]] = (int(row["ccn"]), int(row["nloc"])) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="origin/main", help="git ref to compare against") + parser.add_argument("--repo", default=".", help="repository root") + parser.add_argument("--ccn", type=int, default=DEFAULT_CCN) + parser.add_argument("--nloc", type=int, default=DEFAULT_NLOC) + parser.add_argument( + "--paths", + nargs="*", + default=["."], + help="pathspecs to diff (default: whole tree)", + ) + args = parser.parse_args() + repo = Path(args.repo).resolve() + try: + files = changed_files(repo, args.base, args.paths, set(EXTS)) + except subprocess.CalledProcessError as exc: + print(f"git diff failed: {exc}", file=sys.stderr) + return 2 + if not files: + print("check-complexity: no changed source files") + return 0 + + failures: list[str] = [] + for rel in files: + language = EXTS[Path(rel).suffix] + head = file_at(repo, "HEAD", rel) + if head is None: + continue + base_src = file_at(repo, args.base, rel) + head_funcs = index_funcs(lizard_rows(head, rel, language)) + base_funcs = index_funcs(lizard_rows(base_src, rel, language)) if base_src is not None else {} + for name, (ccn, nloc) in sorted(head_funcs.items()): + if name not in base_funcs: + if ccn > args.ccn or nloc > args.nloc: + failures.append( + f"NEW {rel}::{name} CCN={ccn} NLOC={nloc} (limits {args.ccn}/{args.nloc})" + ) + continue + old_ccn, _old_nloc = base_funcs[name] + if ccn > old_ccn: + failures.append(f"RISE {rel}::{name} CCN {old_ccn} -> {ccn}") + + if failures: + print("check-complexity: FAIL", file=sys.stderr) + for line in failures: + print(line, file=sys.stderr) + return 1 + print(f"check-complexity: PASS ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-check-complexity.sh b/scripts/test-check-complexity.sh new file mode 100755 index 0000000..1dd88d4 --- /dev/null +++ b/scripts/test-check-complexity.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Proving test: a new CCN-12 function fails; an unchanged hotspot does not. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECK="${SCRIPT_DIR}/check-complexity.py" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +git -C "$TMP" init -q +git -C "$TMP" config user.email "sa@example.test" +git -C "$TMP" config user.name "SA" +cat > "$TMP/hotspot.py" <<'PY' +def hotspot(x): + if x == 1: return 1 + if x == 2: return 2 + if x == 3: return 3 + if x == 4: return 4 + if x == 5: return 5 + if x == 6: return 6 + if x == 7: return 7 + if x == 8: return 8 + if x == 9: return 9 + if x == 10: return 10 + if x == 11: return 11 + return 0 +PY +git -C "$TMP" add hotspot.py +git -C "$TMP" commit -qm base +# Unchanged hotspot must PASS +if ! python3 "$CHECK" --repo "$TMP" --base HEAD --paths .; then + echo "expected PASS on identical HEAD vs HEAD" >&2 + exit 1 +fi +cat > "$TMP/new_messy.py" <<'PY' +def messy(x): + if x == 1: return 1 + if x == 2: return 2 + if x == 3: return 3 + if x == 4: return 4 + if x == 5: return 5 + if x == 6: return 6 + if x == 7: return 7 + if x == 8: return 8 + if x == 9: return 9 + if x == 10: return 10 + if x == 11: return 11 + return 0 +PY +git -C "$TMP" add new_messy.py +git -C "$TMP" commit -qm worse +if python3 "$CHECK" --repo "$TMP" --base HEAD~1 --paths .; then + echo "expected FAIL on new CCN-12 function" >&2 + exit 1 +fi +echo "test-check-complexity: PASS" diff --git a/tests/test_ollama_client.py b/tests/test_ollama_client.py index 5d0c078..5d3cdc0 100644 --- a/tests/test_ollama_client.py +++ b/tests/test_ollama_client.py @@ -1,6 +1,5 @@ import json import unittest -from io import BytesIO from unittest.mock import patch from urllib.error import URLError diff --git a/tests/test_quality_gates.py b/tests/test_quality_gates.py new file mode 100644 index 0000000..eb45fbd --- /dev/null +++ b/tests/test_quality_gates.py @@ -0,0 +1,33 @@ +"""Smell gates bound to the unit job: unused imports and new CCN fail.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class QualityGateTests(unittest.TestCase): + def test_ruff_unused_imports(self) -> None: + ruff = shutil.which("ruff") + cmd = ( + [ruff, "check", "--select", "F401,F811", "src", "scripts", "tests"] + if ruff + else [sys.executable, "-m", "ruff", "check", "--select", "F401,F811", "src", "scripts", "tests"] + ) + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + def test_complexity_gate_proves_new_ccn_fails(self) -> None: + script = ROOT / "scripts" / "test-check-complexity.sh" + proc = subprocess.run(["bash", str(script)], cwd=ROOT, capture_output=True, text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("test-check-complexity: PASS", proc.stdout) + + +if __name__ == "__main__": + unittest.main()