Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
137 changes: 137 additions & 0 deletions scripts/check-complexity.py
Original file line number Diff line number Diff line change
@@ -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())
54 changes: 54 additions & 0 deletions scripts/test-check-complexity.sh
Original file line number Diff line number Diff line change
@@ -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"
1 change: 0 additions & 1 deletion tests/test_ollama_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json
import unittest
from io import BytesIO
from unittest.mock import patch
from urllib.error import URLError

Expand Down
33 changes: 33 additions & 0 deletions tests/test_quality_gates.py
Original file line number Diff line number Diff line change
@@ -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()
Loading