The operator's rule (2026-11-03): nothing is declared 90/5 before every make test* target of the project ran green on the exact head. kvb-check-gate holds it as the single source and gate.py backs it: list parses the Makefile make would read and its includes statically (never running make, whose $(shell ...) would execute), status reports green, red or missing per target for HEAD and says gate: green only when all are green on a clean tracked tree, and run executes known targets one by one as make -C with output written straight to a log (no pipe hiding the exit code), marking a target red on a non-zero exit, a make: *** or make[N]: *** line, or a project --fail-regex such as infinito-nexus' FAILED TARGETS. Results live per sha in the git common dir under a lock, shared by linked worktrees; a new commit or a target that edits tracked files invalidates them. kvb-check-confidence and kvb-git-pr-label point to gate.py status instead of restating the rule; the README places the skill. test_gate covers parsing with includes, green, red and fail-pattern runs, stale results after a commit, dirty trees, worktrees and usage errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
9.3 KiB
Python
228 lines
9.3 KiB
Python
"""Exercise gate.py: static `make test*` listing, runs judged per target, and the per-HEAD gate."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py"
|
|
GREEN = {
|
|
"Makefile": (
|
|
"include mk/*.mk\n"
|
|
"-include absent.mk\n"
|
|
"include $(EXTRA_MK)\n"
|
|
".PHONY: test build\n"
|
|
"VERSION := 1:2\n"
|
|
"test: test-green\n"
|
|
"\t@echo all green\n"
|
|
"build:\n"
|
|
"\t@echo build\n"
|
|
"test-%:\n"
|
|
"\t@echo pattern $@\n"
|
|
"define BLOCK\n"
|
|
"test-in-define:\n"
|
|
"\t@echo never\n"
|
|
"endef\n"
|
|
),
|
|
"mk/green.mk": (
|
|
"test-green:\n"
|
|
"\t@echo green\n"
|
|
"test-green: FOO = bar\n"
|
|
"test-only-var: FOO = bar\n"
|
|
),
|
|
"tracked.txt": "one\n",
|
|
}
|
|
RED = {
|
|
"Makefile": (
|
|
".PHONY: test test-red test-quiet test-swallow test-dirty\n"
|
|
"test:\n"
|
|
"\t@echo fine\n"
|
|
"test-red:\n"
|
|
"\t@echo boom\n"
|
|
"\t@exit 1\n"
|
|
"test-quiet:\n"
|
|
'\t@echo "FAILED TARGETS: test-x"\n'
|
|
"test-swallow:\n"
|
|
"\t-@$(MAKE) --no-print-directory test-red\n"
|
|
"test-dirty:\n"
|
|
"\t@echo changed >> tracked.txt\n"
|
|
),
|
|
"tracked.txt": "one\n",
|
|
}
|
|
USAGE = {"Makefile": "test:\n\t@true\nbuild:\n\t@true\n"}
|
|
FAILED = ("--fail-regex", r"FAILED TARGETS: \S")
|
|
|
|
|
|
class GateCase(unittest.TestCase):
|
|
files: ClassVar[dict[str, str]]
|
|
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self.root = Path(tmp.name).resolve()
|
|
self.repo = self.root / "repo"
|
|
self.logs = self.root / "logs"
|
|
self.repo.mkdir()
|
|
self.env = {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": tmp.name,
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
"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")
|
|
for name, text in self.files.items():
|
|
path = self.repo / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
self._git("add", ".")
|
|
self._git("commit", "-q", "-m", "init")
|
|
|
|
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 gate(self, *argv: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, str(SCRIPT), *argv], env=self.env, capture_output=True, text=True, check=False
|
|
)
|
|
|
|
def run_targets(self, *targets: str, extra: tuple[str, ...]) -> subprocess.CompletedProcess:
|
|
flags = [part for target in targets for part in ("--target", target)]
|
|
return self.gate("run", "--repo", str(self.repo), *flags, "--log-dir", str(self.logs), *extra)
|
|
|
|
def status(self, repo: Path) -> tuple[int, dict]:
|
|
result = self.gate("status", "--repo", str(repo), "--json")
|
|
self.assertIn(result.returncode, (0, 1), result.stderr)
|
|
return result.returncode, json.loads(result.stdout)
|
|
|
|
def last_status_line(self) -> str:
|
|
return self.gate("status", "--repo", str(self.repo)).stdout.splitlines()[-1]
|
|
|
|
|
|
class TestGreenPath(GateCase):
|
|
files = GREEN
|
|
|
|
def test_list_reads_includes_and_skips_non_test_names(self):
|
|
result = self.gate("list", "--repo", str(self.repo), "--json")
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(
|
|
json.loads(result.stdout),
|
|
[{"target": "test", "defined": "Makefile:6"}, {"target": "test-green", "defined": "mk/green.mk:1"}],
|
|
)
|
|
self.assertIn("include $(EXTRA_MK) not resolved", result.stderr)
|
|
self.assertNotIn("absent.mk", result.stderr)
|
|
|
|
def test_gate_turns_green_and_a_new_commit_reopens_it(self):
|
|
code, before = self.status(self.repo)
|
|
self.assertEqual((code, before["gate"], before["open"]), (1, "open", ["missing: test, test-green"]))
|
|
self.assertEqual(self.last_status_line(), "gate: open (missing: test, test-green)")
|
|
|
|
result = self.run_targets("test", "test-green", extra=())
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
log = self.logs / f"gate-test-{self._git('rev-parse', 'HEAD')[:8]}.log"
|
|
self.assertEqual(result.stdout.splitlines()[0], f"green\ttest\t{log}\t-")
|
|
self.assertIn("all green", log.read_text(encoding="utf-8"))
|
|
|
|
code, after = self.status(self.repo)
|
|
self.assertEqual((code, after["gate"]), (0, "green"))
|
|
self.assertEqual({r["target"]: r["status"] for r in after["targets"]}, {"test": "green", "test-green": "green"})
|
|
self.assertEqual(self.last_status_line(), "gate: green")
|
|
|
|
self._git("commit", "-q", "--allow-empty", "-m", "next")
|
|
code, reopened = self.status(self.repo)
|
|
self.assertEqual((code, reopened["open"]), (1, ["missing: test, test-green"]))
|
|
|
|
def test_results_are_shared_by_linked_worktrees(self):
|
|
self.assertEqual(self.run_targets("test", "test-green", extra=()).returncode, 0)
|
|
worktree = self.root / "wt"
|
|
self._git("worktree", "add", "-q", "--detach", str(worktree))
|
|
self.assertEqual(self.status(worktree)[0], 0)
|
|
|
|
def test_dirty_tracked_file_blocks_run_untracked_is_reported(self):
|
|
(self.repo / "scratch.txt").write_text("x\n", encoding="utf-8")
|
|
result = self.run_targets("test-green", extra=())
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("untracked\t1 file(s), not part of the gate", result.stdout)
|
|
|
|
(self.repo / "tracked.txt").write_text("two\n", encoding="utf-8")
|
|
result = self.run_targets("test", extra=())
|
|
self.assertEqual(result.returncode, 2)
|
|
self.assertIn("tracked files are dirty", result.stderr)
|
|
self.assertIn("tracked.txt", result.stderr)
|
|
code, status = self.status(self.repo)
|
|
self.assertEqual((code, status["open"]), (1, ["missing: test", "dirty: 1 tracked file(s)"]))
|
|
|
|
|
|
class TestRedPath(GateCase):
|
|
files = RED
|
|
|
|
def results(self) -> dict[str, dict]:
|
|
return {r["target"]: r for r in self.status(self.repo)[1]["targets"]}
|
|
|
|
def test_exit_code_fail_regex_and_swallowed_make_error_are_red(self):
|
|
result = self.run_targets("test", "test-red", "test-quiet", "test-swallow", extra=FAILED)
|
|
self.assertEqual(result.returncode, 1, result.stderr)
|
|
rows = self.results()
|
|
self.assertEqual(rows["test"]["status"], "green")
|
|
self.assertEqual(rows["test-red"]["status"], "red")
|
|
self.assertRegex(rows["test-red"]["reason"], r"^exit 2; make: \*\*\* \[Makefile:\d+: test-red\] Error 1$")
|
|
self.assertEqual(rows["test-quiet"]["status"], "red")
|
|
self.assertEqual(rows["test-quiet"]["reason"], "FAILED TARGETS: test-x")
|
|
self.assertEqual(rows["test-quiet"]["fail_regex"], [FAILED[1]])
|
|
self.assertEqual(rows["test-swallow"]["status"], "red")
|
|
self.assertRegex(rows["test-swallow"]["reason"], r"^make\[1\]: \*\*\* ")
|
|
self.assertEqual(rows["test-dirty"]["status"], "missing")
|
|
self.assertEqual(
|
|
self.last_status_line(), "gate: open (missing: test-dirty; red: test-quiet, test-red, test-swallow)"
|
|
)
|
|
|
|
def test_a_fail_pattern_without_its_regex_stays_green(self):
|
|
self.assertEqual(self.run_targets("test-quiet", extra=()).returncode, 0)
|
|
self.assertEqual(self.results()["test-quiet"]["status"], "green")
|
|
|
|
def test_a_target_that_modifies_tracked_files_is_red_and_stops_the_run(self):
|
|
result = self.run_targets("test-dirty", "test", extra=())
|
|
self.assertEqual(result.returncode, 2)
|
|
self.assertIn("test-dirty left tracked files dirty", result.stderr)
|
|
rows = self.results()
|
|
self.assertEqual(rows["test-dirty"]["reason"], "modified tracked files: tracked.txt")
|
|
self.assertEqual(rows["test"]["status"], "missing")
|
|
|
|
|
|
class TestUsage(GateCase):
|
|
files = USAGE
|
|
|
|
def test_usage_errors_exit_2(self):
|
|
bare = self.root / "bare"
|
|
bare.mkdir()
|
|
cases = {
|
|
"no command": self.gate(),
|
|
"run without log dir": self.gate("run", "--repo", str(self.repo), "--target", "test"),
|
|
"run without target": self.gate("run", "--repo", str(self.repo), "--log-dir", str(self.logs)),
|
|
"non-test target": self.run_targets("build", extra=()),
|
|
"unknown target": self.run_targets("test-nope", extra=()),
|
|
"bad regex": self.run_targets("test", extra=("--fail-regex", "(")),
|
|
"no Makefile": self.gate("list", "--repo", str(bare)),
|
|
"missing repo": self.gate("status", "--repo", str(self.root / "absent")),
|
|
}
|
|
(bare / "Makefile").write_text("test:\n\t@true\n", encoding="utf-8")
|
|
cases["not a git repository"] = self.gate("status", "--repo", str(bare))
|
|
for name, result in cases.items():
|
|
with self.subTest(case=name):
|
|
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
|
self.assertFalse(self.logs.exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|