From e581025736737350adc3ef9d1b84ec407339aa82 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 3 Nov 2026 23:58:24 +0100 Subject: [PATCH] feat(kvb-check-gate): prove 90/5 by every make test* target on the exact head 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 --- README.md | 5 + skills/kvb-check-confidence/SKILL.md | 6 +- skills/kvb-check-gate/SKILL.md | 63 +++++ skills/kvb-check-gate/scripts/gate.py | 345 ++++++++++++++++++++++++++ skills/kvb-git-pr-label/SKILL.md | 4 +- tests/test_gate.py | 227 +++++++++++++++++ 6 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 skills/kvb-check-gate/SKILL.md create mode 100755 skills/kvb-check-gate/scripts/gate.py create mode 100644 tests/test_gate.py diff --git a/README.md b/README.md index 3e0ab81..0d7eed8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ flowchart LR check --> check_principles[kvb-check-principles] check --> check_confidence[kvb-check-confidence] check --> check_break_risk[kvb-check-break-risk] + check --> check_gate[kvb-check-gate] org --> org_organize[kvb-org-organize] org --> org_split[kvb-org-split] org --> org_coordinate[kvb-org-coordinate] @@ -155,6 +156,7 @@ flowchart LR sceptic[kvb-check-principles] confidence[kvb-check-confidence] break_risk[kvb-check-break-risk] + gate[kvb-check-gate] end subgraph cat_org [org] democracy[kvb-org-organize] @@ -227,6 +229,9 @@ flowchart LR commit_no_verify --> advocate advocate --> dialectic confidence --> break_risk + confidence --> gate + pr_label --> gate + gate --> democracy confidence -. objective .-> autotune coordinate --> democracy diff --git a/skills/kvb-check-confidence/SKILL.md b/skills/kvb-check-confidence/SKILL.md index 6014b1f..c53351a 100644 --- a/skills/kvb-check-confidence/SKILL.md +++ b/skills/kvb-check-confidence/SKILL.md @@ -32,10 +32,8 @@ Calibration rules: A threshold the operator named ("95% confidence, break risk ≤5%") is a gate on the work for both numbers, handled as the `kvb-check-break-risk` skill's last -section says. Fix confidence of 90 % or more with break risk under 5 % (90/5) -needs the project's full quality gate green on the exact head (in -infinito-nexus `make quality-high`); before that, the numbers are -provisional and the missing gate is the residual. +section says. 90/5 also needs `gate: green` from `gate.py status` of the +`kvb-check-gate` skill, which states the rule and its residuals. Objective: the `kvb-skill-tune` skill's **Objective** section. Two figures with their residuals replace a hedging paragraph and save the operator a diff --git a/skills/kvb-check-gate/SKILL.md b/skills/kvb-check-gate/SKILL.md new file mode 100644 index 0000000..e082dd3 --- /dev/null +++ b/skills/kvb-check-gate/SKILL.md @@ -0,0 +1,63 @@ +--- +name: kvb-check-gate +description: > + Prove 90/5: every `make test*` target green on the exact HEAD. Trigger + before declaring 90/5 or labelling a PR, or on "gate status". +--- + +The gate rule, stated here and nowhere else: nothing is declared 90/5 (fix +confidence of at least 90 %, break risk under 5 %) before every `make test*` +target of the project, each Makefile target named `test` or starting with +`test-`, ran green on the exact head with a clean tracked tree. Until then +the numbers are provisional and every target not green is a residual. + +`${CLAUDE_SKILL_DIR}/scripts/gate.py` is the deterministic part. It reads the +targets from the Makefile and every file it includes without running make, +and keeps the results per commit in `kvb-gate.json` in the git common dir: +linked worktrees share them, a new commit starts from zero. + +## Steps + +1. **List**: `gate.py list --repo `. A warning about an unresolved + include or target expression means the list may be short: read that line + and name the gap. A project without any target keeps the gate open; ask + the operator. +2. **Status**: `gate.py status --repo `. `gate: green` ends here. +3. **Admit**: every gate target is heavy (`make test` and its siblings run + whole suites, often containers). Ask the resource chair of the + `kvb-org-organize` skill per target (what, where, how long, disk), wait + for the grant, post the release to its news. Only a single test module + self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py + admit --kind test-nostack`), and it never turns a gate target green. A + hotfix takes the next free gate slot. +4. **Run** the missing and red targets, tracked tree clean, in the + background; give the operator `tail -f` on the log: + + ```bash + gate.py run --repo --target [--target ...] --log-dir /tmp/ [--fail-regex ...] + ``` + + Pass one `--fail-regex` per failure line the project prints while exiting + 0; infinito-nexus needs `FAILED TARGETS: \S`. A target is red on a + non-zero exit, a `make: ***` line, a matching regex, or tracked files it + modified; the last one stops the run. +5. **Judge** every red target by the line `status` prints and the log around + it: caused by the change, already red on the default branch, or the + environment. Fix the cause and run again; a red target never counts as + green because it looks unrelated. +6. **Status** once more; its last line is the verdict. + +## Rules + +- Only `gate.py run` on this head marks a target green. A peer's report, a + CI run, an older commit or a run on a dirty tree never does. +- 90/5 is allowed only on `gate: green` for the exact head: in the + `kvb-check-confidence` block, the `90/5` label of the `kvb-git-pr-label` + skill and the bar of the `kvb-org-split` skill. +- Never edit `kvb-gate.json` by hand. + +## Output + +| Target | Status | Log | Line | + +Then the `gate:` line verbatim. diff --git a/skills/kvb-check-gate/scripts/gate.py b/skills/kvb-check-gate/scripts/gate.py new file mode 100755 index 0000000..139358c --- /dev/null +++ b/skills/kvb-check-gate/scripts/gate.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""List, run and check a project's `make test*` targets against the current HEAD. + +Args: + list --repo [--json]: the Makefile targets named `test` or `test-*`, + parsed statically from the Makefile make reads in and every file + it includes; make is never run. + status --repo [--json]: per listed target green, red or missing for + HEAD, from `kvb-gate.json` in the git common dir. + run --repo --target [--target ...] --log-dir + [--fail-regex ...]: run each target as `make -C ` with + stdout and stderr in /gate--.log and record the + result for HEAD. A target is red on a non-zero exit, a `make: ***` + line, a matching --fail-regex, or tracked files it modified. + +Output: + list: one `\t:` per target. + status: one `\t\t\t\t` per target, + then `gate: green`, or `gate: open (...)` naming what is missing. + run: one `\t\t\t` per target. + --json prints the same data as JSON. + +Exit codes: + list: 0 done, 2 usage error. + status: 0 gate green, 1 gate open, 2 usage error. + run: 0 every target green, 1 a target red, 2 usage error, dirty tracked + files or HEAD moved during a run. +""" + +from __future__ import annotations + +import argparse +import fcntl +import glob +import json +import os +import re +import subprocess +import sys +import tempfile +from datetime import UTC, datetime +from pathlib import Path +from typing import NoReturn + +MAKEFILES = ("GNUmakefile", "makefile", "Makefile") +INCLUDE = re.compile(r"^\s*(?P-include|sinclude|include)\s+(?P.+)$") +DEFINE = re.compile(r"^\s*(?:(?:export|override|private)\s+)*define\b") +ENDEF = re.compile(r"^\s*endef\b") +CONDITIONAL = re.compile(r"^\s*(?:ifeq|ifneq|ifdef|ifndef|else|endif)\b") +ASSIGNMENT = re.compile( + r"^\s*(?:(?:export|override|private|unexport)\s+)*[^\s:#=]+\s*(?::{1,3}=|[?+!]?=)" +) +COMMENT = re.compile(r"(? NoReturn: + print(f"gate: {message}", file=sys.stderr) + sys.exit(2) + + +def logical_lines(path: Path) -> list[tuple[int, str]]: + lines: list[tuple[int, str]] = [] + start, buffer = 0, "" + for number, raw in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + if not buffer: + start = number + if (len(raw) - len(raw.rstrip("\\"))) % 2: + buffer += raw[:-1] + " " + continue + lines.append((start, buffer + raw)) + buffer = "" + if buffer: + lines.append((start, buffer)) + return lines + + +def rule_head(line: str) -> tuple[str, str] | None: + depth = 0 + for index, char in enumerate(line): + if char in "({": + depth += 1 + elif char in ")}": + depth = max(depth - 1, 0) + elif depth == 0 and char == "=": + return None + elif depth == 0 and char == ":": + rest = line[index:] + if re.match(r":{1,3}=", rest): + return None + width = 2 if rest.startswith("::") else 1 + return line[:index].rstrip("&"), line[index + width:] + return None + + +def is_test(name: str) -> bool: + return name == "test" or (name.startswith("test-") and "%" not in name) + + +def parse(path: Path, repo: Path, found: dict, seen: set) -> None: + resolved = path.resolve() + if resolved in seen: + return + seen.add(resolved) + shown = os.path.relpath(resolved, repo) + define_depth = 0 + for number, line in logical_lines(path): + if define_depth: + if DEFINE.match(line): + define_depth += 1 + elif ENDEF.match(line): + define_depth -= 1 + continue + if line.startswith("\t"): + continue + if DEFINE.match(line): + define_depth = 1 + continue + line = COMMENT.sub("", line) + if not line.strip() or CONDITIONAL.match(line): + continue + include = INCLUDE.match(line) + if include: + for word in include.group("paths").split(): + if "$" in word: + print(f"gate: {shown}:{number}: include {word} not resolved", file=sys.stderr) + continue + matches = sorted(glob.glob(str(repo / os.path.expanduser(word)))) + if not matches and include.group("kind") == "include": + print(f"gate: {shown}:{number}: include {word} not found", file=sys.stderr) + for match in matches: + parse(Path(match), repo, found, seen) + continue + if ASSIGNMENT.match(line): + continue + head = rule_head(line) + if head is None or ASSIGNMENT.match(head[1]): + continue + for name in head[0].split(): + if "$" in name: + print(f"gate: {shown}:{number}: target {name} not resolved", file=sys.stderr) + elif is_test(name) and name not in found: + found[name] = f"{shown}:{number}" + + +def targets(repo: Path) -> dict[str, str]: + makefile = next((repo / n for n in MAKEFILES if (repo / n).is_file()), None) + if makefile is None: + fail(f"{repo} has no {', '.join(MAKEFILES)}") + found: dict[str, str] = {} + parse(makefile, repo, found, set()) + return dict(sorted(found.items())) + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=False + ) + if result.returncode != 0: + fail(f"git {' '.join(args)}: {result.stderr.strip()}") + return result.stdout + + +def head(repo: Path) -> str: + return git(repo, "rev-parse", "--verify", "HEAD^{commit}").strip() + + +def tree(repo: Path) -> tuple[list[str], list[str]]: + dirty, untracked = [], [] + for line in git(repo, "status", "--porcelain=v1", "--untracked-files=all").splitlines(): + (untracked if line.startswith("??") else dirty).append(line[3:]) + return dirty, untracked + + +def state_path(repo: Path) -> Path: + return Path(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()) / STATE + + +def load(path: Path) -> dict: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except ValueError: + fail(f"{path} is not valid JSON; move it aside to start over") + if not isinstance(data, dict): + fail(f"{path} holds no object; move it aside to start over") + return data + + +def record(path: Path, sha: str, target: str, entry: dict) -> None: + # Linked worktrees share the git common dir, so parallel runs write this file. + with open(path.with_name(f"{STATE}.lock"), "w", encoding="utf-8") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + data = load(path) + data.setdefault(sha, {})[target] = entry + handle, temp = tempfile.mkstemp(dir=path.parent, prefix=f".{STATE}.") + with os.fdopen(handle, "w", encoding="utf-8") as out: + json.dump(data, out, indent=1, sort_keys=True) + out.write("\n") + os.replace(temp, path) + + +def first_failure(log: Path, checks: list[re.Pattern]) -> str | None: + with open(log, encoding="utf-8", errors="replace") as handle: + for line in handle: + if MAKE_ERROR.search(line) or any(check.search(line) for check in checks): + return line.strip()[:LINE_CHARS] + return None + + +def repository(value: str) -> Path: + repo = Path(value).resolve() + if not repo.is_dir(): + fail(f"{value} is no directory") + return repo + + +def cmd_list(args: argparse.Namespace) -> int: + found = targets(repository(args.repo)) + if args.json: + print(json.dumps([{"target": t, "defined": d} for t, d in found.items()], indent=1)) + return 0 + for target, defined in found.items(): + print(f"{target}\t{defined}") + return 0 + + +def cell(value: str | None) -> str: + return "-" if value is None else value + + +def cmd_status(args: argparse.Namespace) -> int: + repo = repository(args.repo) + found = targets(repo) + sha = head(repo) + state = load(state_path(repo)) + dirty, untracked = tree(repo) + rows = [] + for target in found: + entry = state[sha].get(target) if sha in state else None + rows.append({"target": target, "status": "missing"} if entry is None else {"target": target, **entry}) + missing = [r["target"] for r in rows if r["status"] == "missing"] + red = [r["target"] for r in rows if r["status"] == "red"] + reasons = [] + if not found: + reasons.append("no make test* target found") + if missing: + reasons.append(f"missing: {', '.join(missing)}") + if red: + reasons.append(f"red: {', '.join(red)}") + if dirty: + reasons.append(f"dirty: {len(dirty)} tracked file(s)") + gate = "open" if reasons else "green" + if args.json: + print(json.dumps( + {"sha": sha, "gate": gate, "open": reasons, "targets": rows, + "dirty": dirty, "untracked": untracked}, + indent=1, + )) + else: + print(f"head\t{sha}") + for r in rows: + print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))])) + if untracked: + print(f"untracked\t{len(untracked)} file(s), not part of the gate") + print("gate: green" if gate == "green" else f"gate: open ({'; '.join(reasons)})") + return 0 if gate == "green" else 1 + + +def cmd_run(args: argparse.Namespace) -> int: + repo = repository(args.repo) + checks = [] + if args.fail_regex is not None: + try: + checks = [re.compile(pattern) for pattern in args.fail_regex] + except re.error as error: + fail(f"--fail-regex: {error}") + found = targets(repo) + unknown = [t for t in args.target if t not in found] + if unknown: + fail(f"not a make test* target of {repo}: {', '.join(unknown)}") + sha = head(repo) + dirty, untracked = tree(repo) + if dirty: + fail(f"tracked files are dirty, commit or drop them first: {', '.join(dirty)}") + if untracked: + print(f"untracked\t{len(untracked)} file(s), not part of the gate") + log_dir = Path(args.log_dir).resolve() + log_dir.mkdir(parents=True, exist_ok=True) + state = state_path(repo) + red = False + for target in dict.fromkeys(args.target): + log = log_dir / f"gate-{UNSAFE.sub('_', target)}-{sha[:8]}.log" + with open(log, "w", encoding="utf-8") as out: + code = subprocess.run( + ["make", "-C", str(repo), target], + stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, check=False, + ).returncode + if head(repo) != sha: + fail(f"HEAD moved during {target}; nothing recorded for it") + changed, _ = tree(repo) + reasons = [] + if code: + reasons.append(f"exit {code}") + line = first_failure(log, checks) + if line is not None: + reasons.append(line) + if changed: + reasons.append(f"modified tracked files: {', '.join(changed)}") + status = "red" if reasons else "green" + red = red or status == "red" + entry = {"status": status, "log": str(log), + "finished": datetime.now(UTC).isoformat(timespec="seconds"), + "fail_regex": [c.pattern for c in checks]} + if reasons: + entry["reason"] = "; ".join(reasons) + record(state, sha, target, entry) + print("\t".join([status, target, str(log), cell(entry.get("reason"))])) + if changed: + fail(f"{target} left tracked files dirty; remaining targets not run") + return 1 if red else 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True) + for name in ("list", "status"): + command = sub.add_parser(name) + command.add_argument("--repo", required=True) + command.add_argument("--json", action="store_true") + run = sub.add_parser("run") + run.add_argument("--repo", required=True) + run.add_argument("--target", action="append", required=True) + run.add_argument("--log-dir", required=True) + run.add_argument("--fail-regex", action="append") + args = parser.parse_args() + return {"list": cmd_list, "status": cmd_status, "run": cmd_run}[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/kvb-git-pr-label/SKILL.md b/skills/kvb-git-pr-label/SKILL.md index 0d05648..d44cbc6 100644 --- a/skills/kvb-git-pr-label/SKILL.md +++ b/skills/kvb-git-pr-label/SKILL.md @@ -14,8 +14,8 @@ holds: - **`90/5`**: the pull request meets the bar of the `kvb-org-split` skill (fix confidence of at least 90 %, break risk under 5 %), from the `kvb-check-confidence` numbers in its body or freshly assessed, and only - after the project's full quality gate passed on the PR's exact head; a - hotfix takes the next free gate slot. + when `gate.py status` of the `kvb-check-gate` skill prints `gate: green` + with the PR's exact head checked out. ## Procedure diff --git a/tests/test_gate.py b/tests/test_gate.py new file mode 100644 index 0000000..2f51ab3 --- /dev/null +++ b/tests/test_gate.py @@ -0,0 +1,227 @@ +"""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()