Merge fix/kvb-check-gate-excluded-targets into unify/skills-20261111
This commit is contained in:
commit
45db829e45
3 files changed
+98
-13
No files matched your search
@@ -11,9 +11,11 @@ confidence ≥ 90 %, break risk ≤ 5 %, cost and time ≤ 100 % in
|
|||||||
the cause) before every `make test*` target of the project, each Makefile target named `test` or starting with
|
the cause) 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
|
`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.
|
the numbers are provisional and every target not green is a residual.
|
||||||
|
Targets in the operator's `gate_excluded_targets` are excluded, not
|
||||||
|
residuals.
|
||||||
|
|
||||||
`${CLAUDE_SKILL_DIR}/scripts/gate.py` reads the targets from the Makefile and
|
`${CLAUDE_SKILL_DIR}/scripts/gate.py` reads the targets from the Makefile and
|
||||||
its includes without running make, and keeps results per commit in
|
its includes and keeps results per commit in
|
||||||
`kvb-gate.json` in the git common dir: linked worktrees share them, a new
|
`kvb-gate.json` in the git common dir: linked worktrees share them, a new
|
||||||
commit starts from zero.
|
commit starts from zero.
|
||||||
|
|
||||||
@@ -30,8 +32,8 @@ commit starts from zero.
|
|||||||
3. **Status**: `gate.py status --repo <root>`. `gate: green` ends here; a
|
3. **Status**: `gate.py status --repo <root>`. `gate: green` ends here; a
|
||||||
`missing hotfix` reason means back to step 1.
|
`missing hotfix` reason means back to step 1.
|
||||||
4. **Admit**: every gate target is heavy and runs only inside
|
4. **Admit**: every gate target is heavy and runs only inside
|
||||||
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5),
|
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5).
|
||||||
which grants by rule and releases on exit. Only a single test module
|
Only a single test module
|
||||||
self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py
|
self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py
|
||||||
admit --kind test-nostack`), and it never turns a gate target green. A
|
admit --kind test-nostack`), and it never turns a gate target green. A
|
||||||
hotfix gate adds `--hotfix <slug>` and takes the next free gate slot.
|
hotfix gate adds `--hotfix <slug>` and takes the next free gate slot.
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ Args:
|
|||||||
parsed statically from the Makefile make reads in <dir> and every file
|
parsed statically from the Makefile make reads in <dir> and every file
|
||||||
it includes; make is never run.
|
it includes; make is never run.
|
||||||
status --repo <dir> [--json]: per listed target green, red or missing for
|
status --repo <dir> [--json]: per listed target green, red or missing for
|
||||||
HEAD, from `kvb-gate.json` in the git common dir; `hotfix.py pending
|
HEAD, from `kvb-gate.json` in the git common dir, or excluded when the
|
||||||
--target <t> ...` of the kvb-git-hotfix skill, given the listed
|
list gate_excluded_targets in CONFIG names it; an excluded target never
|
||||||
targets, keeps the gate open while a merged hotfix that turns one of
|
keeps the gate open, excluding every target does. CONFIG is
|
||||||
them green is missing from HEAD.
|
.claude/agents-council/config.json in the passwd home of the account
|
||||||
|
running gate.py, never $HOME. `hotfix.py pending --target <t> ...`
|
||||||
|
of the kvb-git-hotfix skill, given the other listed targets, keeps the
|
||||||
|
gate open while a merged hotfix that turns one of them green is
|
||||||
|
missing from HEAD.
|
||||||
run --repo <dir> --target <t> [--target ...] --log-dir <dir>
|
run --repo <dir> --target <t> [--target ...] --log-dir <dir>
|
||||||
[--fail-regex <re> ...]: under the worktree lock, run each target as
|
[--fail-regex <re> ...]: under the worktree lock, run each target as
|
||||||
`make -C <dir> <t>` with stdout and stderr in
|
`make -C <dir> <t>` with stdout and stderr in
|
||||||
@@ -44,7 +48,8 @@ Args:
|
|||||||
Output:
|
Output:
|
||||||
list: one `<target>\t<file>:<line>` per target.
|
list: one `<target>\t<file>:<line>` per target.
|
||||||
status: one `<status>\t<target>\t<log>\t<finished>\t<reason>` per target,
|
status: one `<status>\t<target>\t<log>\t<finished>\t<reason>` per target,
|
||||||
then `gate: green`, or `gate: open (...)` naming what is missing,
|
then `gate: green`, `gate: green (excluded: <target>, ... by
|
||||||
|
<CONFIG>)`, or `gate: open (...)` naming what is missing,
|
||||||
`missing hotfix: <slug>, ...` included.
|
`missing hotfix: <slug>, ...` included.
|
||||||
run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void
|
run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void
|
||||||
`void: merge main for hotfix <slug>, then rerun`.
|
`void: merge main for hotfix <slug>, then rerun`.
|
||||||
@@ -54,8 +59,8 @@ Output:
|
|||||||
Exit codes:
|
Exit codes:
|
||||||
list: 0 done, 2 usage error.
|
list: 0 done, 2 usage error.
|
||||||
status: 0 gate green, 1 gate open, 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
|
run: 0 every target green, 1 a target red, 2 usage error, an excluded
|
||||||
files or HEAD moved during a run, 3 void: the remaining targets are
|
target, dirty tracked files or HEAD moved during a run, 3 void: the remaining targets are
|
||||||
neither run nor recorded, 75 (EX_TEMPFAIL) busy: another run or hold
|
neither run nor recorded, 75 (EX_TEMPFAIL) busy: another run or hold
|
||||||
holds the worktree, or a sync still holds it after SYNC_WAIT_SECONDS.
|
holds the worktree, or a sync still holds it after SYNC_WAIT_SECONDS.
|
||||||
hold: the command's exit code unchanged, 128+N when signal N ended it;
|
hold: the command's exit code unchanged, 128+N when signal N ended it;
|
||||||
@@ -71,6 +76,7 @@ import fcntl
|
|||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import signal
|
import signal
|
||||||
@@ -103,6 +109,8 @@ POLL_SECONDS = 0.1
|
|||||||
BUSY = os.EX_TEMPFAIL
|
BUSY = os.EX_TEMPFAIL
|
||||||
FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
||||||
HOTFIX = Path(__file__).resolve().parents[2] / "kvb-git-hotfix" / "scripts" / "hotfix.py"
|
HOTFIX = Path(__file__).resolve().parents[2] / "kvb-git-hotfix" / "scripts" / "hotfix.py"
|
||||||
|
CONFIG = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".claude" / "agents-council" / "config.json"
|
||||||
|
EXCLUDED = "gate_excluded_targets"
|
||||||
|
|
||||||
|
|
||||||
def fail(message: str) -> NoReturn:
|
def fail(message: str) -> NoReturn:
|
||||||
@@ -380,6 +388,18 @@ def repository(value: str) -> Path:
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def excluded() -> set[str]:
|
||||||
|
if not CONFIG.is_file():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
listed = json.loads(CONFIG.read_text(encoding="utf-8")).get(EXCLUDED, [])
|
||||||
|
except (OSError, ValueError, AttributeError) as error:
|
||||||
|
fail(f"cannot read {EXCLUDED} from {CONFIG}: {error}")
|
||||||
|
if not isinstance(listed, list) or not all(isinstance(target, str) for target in listed):
|
||||||
|
fail(f"{EXCLUDED} in {CONFIG} must be a list of target names")
|
||||||
|
return set(listed)
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args: argparse.Namespace) -> int:
|
def cmd_list(args: argparse.Namespace) -> int:
|
||||||
found = targets(repository(args.repo))
|
found = targets(repository(args.repo))
|
||||||
if args.json:
|
if args.json:
|
||||||
@@ -400,22 +420,30 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||||||
sha = head(repo)
|
sha = head(repo)
|
||||||
state = load(state_path(repo))
|
state = load(state_path(repo))
|
||||||
dirty, untracked = tree(repo)
|
dirty, untracked = tree(repo)
|
||||||
|
skip = excluded()
|
||||||
|
required = [target for target in found if target not in skip]
|
||||||
|
skipped = [target for target in found if target in skip]
|
||||||
rows = []
|
rows = []
|
||||||
for target in found:
|
for target in found:
|
||||||
entry = state[sha].get(target) if sha in state else None
|
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})
|
if target in skip:
|
||||||
|
rows.append({"target": target, "status": "excluded", "reason": f"{EXCLUDED} in {CONFIG}"})
|
||||||
|
else:
|
||||||
|
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"]
|
missing = [r["target"] for r in rows if r["status"] == "missing"]
|
||||||
red = [r["target"] for r in rows if r["status"] == "red"]
|
red = [r["target"] for r in rows if r["status"] == "red"]
|
||||||
reasons = []
|
reasons = []
|
||||||
if not found:
|
if not found:
|
||||||
reasons.append("no make test* target found")
|
reasons.append("no make test* target found")
|
||||||
|
elif not required:
|
||||||
|
reasons.append("every make test* target is excluded")
|
||||||
if missing:
|
if missing:
|
||||||
reasons.append(f"missing: {', '.join(missing)}")
|
reasons.append(f"missing: {', '.join(missing)}")
|
||||||
if red:
|
if red:
|
||||||
reasons.append(f"red: {', '.join(red)}")
|
reasons.append(f"red: {', '.join(red)}")
|
||||||
if dirty:
|
if dirty:
|
||||||
reasons.append(f"dirty: {len(dirty)} tracked file(s)")
|
reasons.append(f"dirty: {len(dirty)} tracked file(s)")
|
||||||
hotfixes = missing_hotfixes(repo, list(found)) if hotfix_checks() else []
|
hotfixes = missing_hotfixes(repo, required) if required and hotfix_checks() else []
|
||||||
if hotfixes:
|
if hotfixes:
|
||||||
reasons.append(f"missing hotfix: {', '.join(hotfixes)}")
|
reasons.append(f"missing hotfix: {', '.join(hotfixes)}")
|
||||||
gate = "open" if reasons else "green"
|
gate = "open" if reasons else "green"
|
||||||
@@ -431,7 +459,12 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||||||
print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))]))
|
print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))]))
|
||||||
if untracked:
|
if untracked:
|
||||||
print(f"untracked\t{len(untracked)} file(s), not part of the gate")
|
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)})")
|
if reasons:
|
||||||
|
print(f"gate: open ({'; '.join(reasons)})")
|
||||||
|
elif skipped:
|
||||||
|
print(f"gate: green (excluded: {', '.join(skipped)} by {CONFIG})")
|
||||||
|
else:
|
||||||
|
print("gate: green")
|
||||||
return 0 if gate == "green" else 1
|
return 0 if gate == "green" else 1
|
||||||
|
|
||||||
|
|
||||||
@@ -447,6 +480,9 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|||||||
unknown = [t for t in args.target if t not in found]
|
unknown = [t for t in args.target if t not in found]
|
||||||
if unknown:
|
if unknown:
|
||||||
fail(f"not a make test* target of {repo}: {', '.join(unknown)}")
|
fail(f"not a make test* target of {repo}: {', '.join(unknown)}")
|
||||||
|
barred = sorted(set(args.target) & excluded())
|
||||||
|
if barred:
|
||||||
|
fail(f"excluded from every gate by {EXCLUDED} in {CONFIG}: {', '.join(barred)}")
|
||||||
with worktree_lock(repo, ["gate.py", "run", "--repo", str(repo), *target_flags(args.target)], []) as lock:
|
with worktree_lock(repo, ["gate.py", "run", "--repo", str(repo), *target_flags(args.target)], []) as lock:
|
||||||
sha = head(repo)
|
sha = head(repo)
|
||||||
dirty, untracked = tree(repo)
|
dirty, untracked = tree(repo)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import contextlib
|
import contextlib
|
||||||
import fcntl
|
import fcntl
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,6 +19,7 @@ import time
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py"
|
SCRIPT = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gate.py"
|
||||||
@@ -68,6 +71,7 @@ RED = {
|
|||||||
}
|
}
|
||||||
USAGE = {"Makefile": "test:\n\t@true\nbuild:\n\t@true\n"}
|
USAGE = {"Makefile": "test:\n\t@true\nbuild:\n\t@true\n"}
|
||||||
VOID = {"Makefile": ".PHONY: test test-a test-b\ntest:\n\t@echo all\ntest-a:\n\t@echo a\ntest-b:\n\t@echo b\n"}
|
VOID = {"Makefile": ".PHONY: test test-a test-b\ntest:\n\t@echo all\ntest-a:\n\t@echo a\ntest-b:\n\t@echo b\n"}
|
||||||
|
ORACLE = {"Makefile": ".PHONY: test test-oracle\ntest:\n\t@echo all\ntest-oracle:\n\t@echo oracle\n"}
|
||||||
FAILED = ("--fail-regex", r"FAILED TARGETS: \S")
|
FAILED = ("--fail-regex", r"FAILED TARGETS: \S")
|
||||||
SYNC_HOLDER = "4242\thotfix.py sync --task t\n"
|
SYNC_HOLDER = "4242\thotfix.py sync --task t\n"
|
||||||
COUNTING_CHILD = (
|
COUNTING_CHILD = (
|
||||||
@@ -142,6 +146,49 @@ class GateCase(unittest.TestCase):
|
|||||||
return self.gate("status", "--repo", str(self.repo)).stdout.splitlines()[-1]
|
return self.gate("status", "--repo", str(self.repo)).stdout.splitlines()[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcluded(GateCase):
|
||||||
|
files = ORACLE
|
||||||
|
|
||||||
|
def call(self, gate: object, command: str, **fields: object) -> tuple[int, str, str]:
|
||||||
|
out, err = io.StringIO(), io.StringIO()
|
||||||
|
with mock.patch.dict(os.environ, self.env, clear=True), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||||
|
try:
|
||||||
|
code = getattr(gate, command)(argparse.Namespace(repo=str(self.repo), **fields))
|
||||||
|
except SystemExit as stop:
|
||||||
|
code = stop.code
|
||||||
|
return code, out.getvalue(), err.getvalue()
|
||||||
|
|
||||||
|
def exclude(self, gate: object, listed: object) -> None:
|
||||||
|
gate.CONFIG.write_text(json.dumps({"gate_excluded_targets": listed}), encoding="utf-8")
|
||||||
|
|
||||||
|
def test_an_excluded_target_is_shown_excluded_never_required_and_refused_by_run(self):
|
||||||
|
gate = load_gate()
|
||||||
|
gate.CONFIG = self.root / "config.json"
|
||||||
|
run = {"log_dir": str(self.logs), "fail_regex": None}
|
||||||
|
self.assertEqual(self.call(gate, "cmd_run", target=["test"], **run)[0], 0)
|
||||||
|
code, out, _ = self.call(gate, "cmd_status", json=True)
|
||||||
|
self.assertEqual((code, json.loads(out)["open"]), (1, ["missing: test-oracle"]))
|
||||||
|
self.exclude(gate, ["test-oracle"])
|
||||||
|
code, out, _ = self.call(gate, "cmd_status", json=True)
|
||||||
|
after = json.loads(out)
|
||||||
|
self.assertEqual((code, after["gate"]), (0, "green"))
|
||||||
|
self.assertEqual({r["target"]: r["status"] for r in after["targets"]}, {"test": "green", "test-oracle": "excluded"})
|
||||||
|
self.assertEqual(self.call(gate, "cmd_status", json=False)[1].splitlines()[-1], f"gate: green (excluded: test-oracle by {gate.CONFIG})")
|
||||||
|
code, _, err = self.call(gate, "cmd_run", target=["test-oracle"], **run)
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
self.assertIn("excluded from every gate by gate_excluded_targets", err)
|
||||||
|
self.exclude(gate, ["test", "test-oracle"])
|
||||||
|
code, out, _ = self.call(gate, "cmd_status", json=True)
|
||||||
|
self.assertEqual((code, json.loads(out)["open"]), (1, ["every make test* target is excluded"]))
|
||||||
|
self.exclude(gate, "test-oracle")
|
||||||
|
self.assertEqual(self.call(gate, "cmd_status", json=False)[0], 2)
|
||||||
|
|
||||||
|
def test_the_exclusion_list_ignores_home_and_environment_overrides(self):
|
||||||
|
with mock.patch.dict(os.environ, {"HOME": str(self.root), "AGENTS_COUNCIL_CONFIG": str(self.root / "config.json")}):
|
||||||
|
gate = load_gate()
|
||||||
|
self.assertEqual(gate.CONFIG, Path(pwd.getpwuid(os.getuid()).pw_dir) / ".claude" / "agents-council" / "config.json")
|
||||||
|
|
||||||
|
|
||||||
class TestGreenPath(GateCase):
|
class TestGreenPath(GateCase):
|
||||||
files = GREEN
|
files = GREEN
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user