🧪 Test / 🧪 Lock + lint (push) Waiting to run
principles.py runs ruff (C901, PLR0913, BLE001, S110, B006, B008, ARG), lizard (CCN, NLOC, parameters per function span) and jscpd (clones) on the lines a change touches and maps each hit to SRP, fail fast, KISS/YAGNI or DRY; a missing tool prints skip, every threshold is a required flag. It reuses the diff parsing of defaults.py, registered in sys.modules before it runs because dataclasses look the module up there, and hands lizard only the suffixes its 1.24.1 readers claim, since lizard parses any other file as C. kvb-code-clean step 3 calls it with McCabe's 10, pylint's 5 parameters, jscpd's 50 tokens and 50 NLOC; make install adds lizard 1.24.1 (pipx) and jscpd 5.4.2 (npm). The lizard and jscpd parsers are tested on fixture output only: neither tool has run for real yet. 🎯 Fix: 90 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: new 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 86 🚦 Gate: ⚠️ time new: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
11 KiB
Python
265 lines
11 KiB
Python
"""Exercise principles.py: principle-lens tool hits on the changed lines of a temporary git repository."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = REPO_ROOT / "skills" / "kvb-check-principles" / "scripts" / "principles.py"
|
|
SPEC = importlib.util.spec_from_file_location("principles_under_test", SCRIPT)
|
|
principles = importlib.util.module_from_spec(SPEC)
|
|
sys.modules[SPEC.name] = principles
|
|
SPEC.loader.exec_module(principles)
|
|
|
|
GIT = shutil.which("git")
|
|
RUFF = shutil.which("ruff")
|
|
LIMITS = ("--ccn", "10", "--nloc", "50", "--params", "5", "--min-tokens", "40")
|
|
SWALLOW = "def old():\n try:\n return 1\n except Exception:\n pass\n"
|
|
ADDED = SWALLOW + "\n\ndef new(unused):\n try:\n return 2\n except Exception:\n pass\n"
|
|
RUFF_HITS = sorted(
|
|
[
|
|
("app.py:8", "KISS/YAGNI", "ruff", "ARG001"),
|
|
("app.py:11", "fail fast", "ruff", "BLE001"),
|
|
("app.py:11", "fail fast", "ruff", "S110"),
|
|
]
|
|
)
|
|
LIZARD_TEXT = (
|
|
"/src/app.py:3: warning: grow has 31 NLOC, 12 CCN, 140 token, 2 PARAM, 40 length, 3 ND\n"
|
|
"/src/a b.cpp:7: warning: Foo::operator () has 5 NLOC, 16 CCN, 50 token, 7 PARAM, 6 length, 1 ND\n"
|
|
"/src/x.py:1: warning: f has 1 NLOC, 20 CCN, 5 token, 0 PARAM, 1 length\n"
|
|
"================================================\n"
|
|
"No thresholds exceeded (cyclomatic_complexity > 10)\n"
|
|
)
|
|
JSCPD_REPORT = {
|
|
"duplicates": [
|
|
{
|
|
"format": "python",
|
|
"lines": 6,
|
|
"fragment": "x = 1\n",
|
|
"tokens": 55,
|
|
"firstFile": {
|
|
"name": "/src/a.py",
|
|
"start": 1,
|
|
"end": 6,
|
|
"startLoc": {"line": 1, "column": 1, "position": 0},
|
|
"endLoc": {"line": 6, "column": 9, "position": 80},
|
|
},
|
|
"secondFile": {
|
|
"name": "/src/b.py",
|
|
"start": 10,
|
|
"end": 15,
|
|
"startLoc": {"line": 10, "column": 1, "position": 120},
|
|
"endLoc": {"line": 15, "column": 9, "position": 200},
|
|
},
|
|
"isNew": False,
|
|
"kind": "exact",
|
|
}
|
|
],
|
|
"statistics": {"total": {"clones": 1}},
|
|
}
|
|
LINES = "".join(f"x{n} = {n}\n" for n in range(1, 31))
|
|
OTHER = "".join(f"y{n} = {n}\n" for n in range(1, 11))
|
|
FAKE_LIZARD = """#!{python}
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
Path({log!r}).write_text(json.dumps(sys.argv[1:]), encoding="utf-8")
|
|
for path in [arg for arg in sys.argv[1:] if arg.endswith(".py")]:
|
|
print(path + ":1: warning: before has 4 NLOC, 12 CCN, 40 token, 1 PARAM, 4 length, 1 ND")
|
|
print(path + ":3: warning: around has 5 NLOC, 12 CCN, 40 token, 1 PARAM, 5 length, 1 ND")
|
|
print(path + ":5: warning: single has 1 NLOC, 12 CCN, 9 token, 1 PARAM, 1 length, 1 ND")
|
|
print(path + ":6: warning: after has 5 NLOC, 12 CCN, 40 token, 1 PARAM, 5 length, 1 ND")
|
|
sys.exit(1)
|
|
"""
|
|
FAKE_JSCPD = """#!{python}
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
args = sys.argv[1:]
|
|
Path({log!r}).write_text(json.dumps(args), encoding="utf-8")
|
|
Path(args[args.index("--output") + 1], "jscpd-report.json").write_text({report!r}, encoding="utf-8")
|
|
"""
|
|
|
|
|
|
def clone(first: tuple[str, int, int], second: tuple[str, int, int], tokens: int) -> dict:
|
|
return {
|
|
"tokens": tokens,
|
|
"firstFile": dict(zip(("name", "start", "end"), first)),
|
|
"secondFile": dict(zip(("name", "start", "end"), second)),
|
|
}
|
|
|
|
|
|
class TestParsers(unittest.TestCase):
|
|
def test_lizard_warnings_with_their_spans(self):
|
|
self.assertEqual(
|
|
principles.parse_lizard(LIZARD_TEXT),
|
|
[
|
|
("/src/app.py", 3, 42, "grow has 31 NLOC, 12 CCN, 140 token, 2 PARAM, 40 length, 3 ND"),
|
|
("/src/a b.cpp", 7, 12, "Foo::operator () has 5 NLOC, 16 CCN, 50 token, 7 PARAM, 6 length, 1 ND"),
|
|
("/src/x.py", 1, 1, "f has 1 NLOC, 20 CCN, 5 token, 0 PARAM, 1 length"),
|
|
],
|
|
)
|
|
|
|
def test_jscpd_report_sides(self):
|
|
self.assertEqual(principles.parse_jscpd(JSCPD_REPORT), [(("/src/a.py", 1, 6), ("/src/b.py", 10, 15), 55)])
|
|
|
|
def test_span_overlap_bounds(self):
|
|
target = principles.Target(Path("/src/app.py"), frozenset({5}))
|
|
self.assertEqual(
|
|
[target.overlaps(*span) for span in ((1, 4), (3, 7), (5, 5), (6, 10))], [False, True, True, False]
|
|
)
|
|
self.assertTrue(principles.Target(Path("/src/app.py"), None).overlaps(100, 200))
|
|
|
|
|
|
class TestPrinciples(unittest.TestCase):
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.tmp = Path(tmp.name).resolve()
|
|
self.repo = self.tmp / "repo"
|
|
self.bin = self.tmp / "bin"
|
|
self.repo.mkdir()
|
|
self.bin.mkdir()
|
|
(self.bin / "git").symlink_to(GIT)
|
|
self.env = {
|
|
"PATH": str(self.bin),
|
|
"HOME": str(self.tmp),
|
|
"TMPDIR": str(self.tmp),
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
"GIT_CEILING_DIRECTORIES": str(self.tmp),
|
|
"GIT_AUTHOR_NAME": "t",
|
|
"GIT_AUTHOR_EMAIL": "t@example.org",
|
|
"GIT_COMMITTER_NAME": "t",
|
|
"GIT_COMMITTER_EMAIL": "t@example.org",
|
|
}
|
|
self._git("init", "-q", "-b", "main")
|
|
|
|
def _git(self, *args: str) -> str:
|
|
return subprocess.run(
|
|
["git", *args], cwd=self.repo, env=self.env, capture_output=True, text=True, check=True
|
|
).stdout
|
|
|
|
def _write(self, files: dict[str, str]) -> None:
|
|
for name, text in files.items():
|
|
(self.repo / name).write_text(text, encoding="utf-8")
|
|
|
|
def _commit(self, files: dict[str, str]) -> None:
|
|
self._write(files)
|
|
self._git("add", ".")
|
|
self._git("commit", "-q", "-m", "base")
|
|
|
|
def _fake(self, name: str, template: str, **values: str) -> Path:
|
|
log = self.tmp / f"{name}.argv"
|
|
script = self.bin / name
|
|
script.write_text(template.format(python=sys.executable, log=str(log), **values), encoding="utf-8")
|
|
script.chmod(0o755)
|
|
return log
|
|
|
|
def _run(self, *args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, str(SCRIPT), *args], env=self.env, capture_output=True, text=True, check=False
|
|
)
|
|
|
|
def _hits(self, *args: str) -> tuple[int, list[tuple]]:
|
|
result = self._run("--repo", str(self.repo), "--json", *LIMITS, *args)
|
|
self.assertIn(result.returncode, (0, 1), result.stderr)
|
|
rows = json.loads(result.stdout)
|
|
return result.returncode, sorted(
|
|
(f"{r['file']}:{r['line']}", r["lens"], r["tool"], r["message"].split()[0]) for r in rows
|
|
)
|
|
|
|
def test_spans_and_clone_sides_filter_to_changed_lines(self):
|
|
self._commit({"app.py": LINES, "other.py": OTHER})
|
|
self._write({"app.py": LINES.replace("x5 = 5\n", "x5 = 50\n")})
|
|
app, other = str(self.repo / "app.py"), str(self.repo / "other.py")
|
|
report = {
|
|
"duplicates": [
|
|
clone((app, 4, 6), (other, 1, 3), 50),
|
|
clone((app, 20, 25), (other, 1, 6), 60),
|
|
clone((other, 2, 4), (app, 1, 5), 70),
|
|
clone((other, 1, 3), (other, 5, 7), 80),
|
|
]
|
|
}
|
|
lizard_log = self._fake("lizard", FAKE_LIZARD)
|
|
jscpd_log = self._fake("jscpd", FAKE_JSCPD, report=json.dumps(report))
|
|
result = self._run("--repo", str(self.repo), *LIMITS)
|
|
self.assertEqual(result.returncode, 1, result.stderr)
|
|
self.assertEqual(result.stderr, "skip: ruff missing\n")
|
|
self.assertEqual(
|
|
result.stdout,
|
|
"app.py:1\tDRY\tjscpd\tclone of other.py:2-4, 70 tokens\n"
|
|
"app.py:3\tSRP\tlizard\taround has 5 NLOC, 12 CCN, 40 token, 1 PARAM, 5 length, 1 ND\n"
|
|
"app.py:4\tDRY\tjscpd\tclone of other.py:1-3, 50 tokens\n"
|
|
"app.py:5\tSRP\tlizard\tsingle has 1 NLOC, 12 CCN, 9 token, 1 PARAM, 1 length, 1 ND\n",
|
|
)
|
|
self.assertEqual(
|
|
json.loads(lizard_log.read_text(encoding="utf-8")),
|
|
["--warnings_only", "--CCN", "10", "--arguments", "5", "--Threshold", "nloc=50", app],
|
|
)
|
|
jscpd_args = json.loads(jscpd_log.read_text(encoding="utf-8"))
|
|
self.assertEqual(jscpd_args[:4], ["--min-tokens", "40", "--reporters", "json"])
|
|
self.assertEqual(jscpd_args[6:], ["--absolute", "--silent", str(self.repo)])
|
|
|
|
def test_missing_tools_are_skipped(self):
|
|
self._commit({"app.py": SWALLOW})
|
|
self._write({"app.py": ADDED})
|
|
result = self._run("--repo", str(self.repo), *LIMITS)
|
|
self.assertEqual(
|
|
(result.returncode, result.stdout, result.stderr),
|
|
(0, "", "skip: ruff missing\nskip: lizard missing\nskip: jscpd missing\n"),
|
|
)
|
|
|
|
@unittest.skipUnless(RUFF, "ruff not installed")
|
|
def test_ruff_flags_added_swallow_not_the_untouched_one(self):
|
|
(self.bin / "ruff").symlink_to(RUFF)
|
|
self._commit({"app.py": SWALLOW})
|
|
self._write({"app.py": ADDED})
|
|
code, hits = self._hits()
|
|
self.assertEqual((code, hits), (1, RUFF_HITS))
|
|
self.assertNotIn("app.py:4", [hit[0] for hit in hits])
|
|
|
|
@unittest.skipUnless(RUFF, "ruff not installed")
|
|
def test_staged_reads_the_index_snapshot(self):
|
|
(self.bin / "ruff").symlink_to(RUFF)
|
|
self._commit({"app.py": SWALLOW})
|
|
self._write({"app.py": ADDED})
|
|
self._git("add", "app.py")
|
|
self._write({"app.py": SWALLOW})
|
|
self.assertEqual(self._hits("--staged"), (1, RUFF_HITS))
|
|
self.assertEqual(self._hits(), (0, []))
|
|
|
|
@unittest.skipUnless(RUFF, "ruff not installed")
|
|
def test_file_counts_every_line_of_an_untracked_file(self):
|
|
(self.bin / "ruff").symlink_to(RUFF)
|
|
self._commit({"app.py": SWALLOW})
|
|
self._write({"extra.py": SWALLOW})
|
|
self.assertEqual(self._hits(), (0, []))
|
|
self.assertEqual(
|
|
self._hits("--file", "extra.py"),
|
|
(1, [("extra.py:4", "fail fast", "ruff", "BLE001"), ("extra.py:4", "fail fast", "ruff", "S110")]),
|
|
)
|
|
|
|
def test_usage_and_git_errors(self):
|
|
self._commit({"app.py": SWALLOW})
|
|
missing = self._run("--repo", str(self.repo), "--ccn", "10", "--nloc", "50", "--params", "5")
|
|
self.assertEqual(missing.returncode, 2)
|
|
self.assertIn("--min-tokens", missing.stderr)
|
|
self.assertEqual(self._run("--repo", str(self.repo)).returncode, 2)
|
|
outside = self.tmp / "plain"
|
|
outside.mkdir()
|
|
self.assertEqual(self._run("--repo", str(outside), *LIMITS).returncode, 2)
|
|
self.assertEqual(self._run("--repo", str(self.repo), *LIMITS, "--file", "missing.py").returncode, 2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|